Skip to content

Commit d005177

Browse files
committed
feat: add Tables app support (tables, columns, rows CRUD)
Add a new tool module for the Nextcloud Tables app with 12 MCP tools covering full CRUD operations across tables, columns, and rows: Tables: list_tables, create_table, update_table, delete_table Columns: list_columns, create_column, update_column, delete_column Rows: list_rows, create_row, update_row, delete_row Column creation supports all five column types (text, number, selection, datetime, usergroup) with their type-specific configuration parameters. Row data is passed as a JSON string mapping column IDs to values, matching the Tables API v1 contract. Availability check: 'tables' in nc.capabilities. Tested against Nextcloud 32.0.8 with Tables 1.0.5.
1 parent c6ee7f9 commit d005177

1 file changed

Lines changed: 336 additions & 0 deletions

File tree

ex_app/lib/all_tools/tables.py

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
import json
4+
from typing import Optional
5+
from langchain_core.tools import tool
6+
from nc_py_api import AsyncNextcloudApp
7+
8+
from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool
9+
10+
11+
async def get_tools(nc: AsyncNextcloudApp):
12+
13+
# --- Tables ---
14+
15+
@tool
16+
@safe_tool
17+
async def list_tables():
18+
"""
19+
List all tables available to the current user in the Nextcloud Tables app
20+
:return: list of tables with their id, title, emoji, ownership, and column/row counts
21+
"""
22+
response = await nc._session._create_adapter().request(
23+
'GET', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables",
24+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
25+
)
26+
return json.dumps(response.json())
27+
28+
@tool
29+
@dangerous_tool
30+
async def create_table(title: str, emoji: Optional[str] = None, template: Optional[str] = None):
31+
"""
32+
Create a new table in the Nextcloud Tables app
33+
:param title: the title for the new table
34+
:param emoji: optional emoji icon for the table (single emoji character)
35+
:param template: optional template to use (e.g. "todo", "members", "weight")
36+
:return: the created table with its id
37+
"""
38+
payload = {'title': title}
39+
if emoji is not None:
40+
payload['emoji'] = emoji
41+
if template is not None:
42+
payload['template'] = template
43+
response = await nc._session._create_adapter().request(
44+
'POST', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables",
45+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
46+
json=payload,
47+
)
48+
return json.dumps(response.json())
49+
50+
@tool
51+
@dangerous_tool
52+
async def update_table(table_id: int, title: Optional[str] = None, emoji: Optional[str] = None, archived: Optional[bool] = None):
53+
"""
54+
Update a table's properties
55+
:param table_id: the id of the table to update (obtainable with list_tables)
56+
:param title: new title for the table
57+
:param emoji: new emoji icon for the table
58+
:param archived: set to true to archive the table, false to unarchive
59+
:return: the updated table
60+
"""
61+
payload = {}
62+
if title is not None:
63+
payload['title'] = title
64+
if emoji is not None:
65+
payload['emoji'] = emoji
66+
if archived is not None:
67+
payload['archived'] = archived
68+
response = await nc._session._create_adapter().request(
69+
'PUT', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables/{table_id}",
70+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
71+
json=payload,
72+
)
73+
return json.dumps(response.json())
74+
75+
@tool
76+
@dangerous_tool
77+
async def delete_table(table_id: int):
78+
"""
79+
Delete a table and all its columns and rows
80+
:param table_id: the id of the table to delete (obtainable with list_tables)
81+
:return: the deleted table
82+
"""
83+
response = await nc._session._create_adapter().request(
84+
'DELETE', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables/{table_id}",
85+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
86+
)
87+
return json.dumps(response.json())
88+
89+
# --- Columns ---
90+
91+
@tool
92+
@safe_tool
93+
async def list_columns(table_id: int):
94+
"""
95+
List all columns defined for a table
96+
:param table_id: the id of the table (obtainable with list_tables)
97+
:return: list of columns with their id, title, type, subtype, and configuration
98+
"""
99+
response = await nc._session._create_adapter().request(
100+
'GET', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables/{table_id}/columns",
101+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
102+
)
103+
return json.dumps(response.json())
104+
105+
@tool
106+
@dangerous_tool
107+
async def create_column(
108+
table_id: int,
109+
title: str,
110+
column_type: str,
111+
subtype: Optional[str] = None,
112+
mandatory: bool = False,
113+
description: Optional[str] = None,
114+
number_prefix: Optional[str] = None,
115+
number_suffix: Optional[str] = None,
116+
number_default: Optional[float] = None,
117+
number_min: Optional[float] = None,
118+
number_max: Optional[float] = None,
119+
number_decimals: Optional[int] = None,
120+
text_default: Optional[str] = None,
121+
text_max_length: Optional[int] = None,
122+
selection_options: Optional[str] = None,
123+
selection_default: Optional[str] = None,
124+
datetime_default: Optional[str] = None,
125+
):
126+
"""
127+
Create a new column in a table.
128+
129+
Available column types and their subtypes:
130+
- "text": subtypes "line" (single line, default), "long" (multi-line), "rich" (rich text), "link" (URL)
131+
- "number": subtypes None (plain number, default), "stars" (rating 0-5), "progress" (percentage 0-100)
132+
- "selection": subtypes None (dropdown, default), "check" (checkbox), "multi" (multi-select)
133+
- "datetime": subtypes None (date and time, default), "date" (date only), "time" (time only)
134+
- "usergroup": no subtypes
135+
136+
Type-specific parameters:
137+
- number columns: number_prefix, number_suffix, number_default, number_min, number_max, number_decimals
138+
- text columns: text_default, text_max_length
139+
- selection columns: selection_options (JSON string, e.g. '[{"id": 1, "label": "Option A"}]'), selection_default
140+
- datetime columns: datetime_default (ISO 8601 format)
141+
142+
:param table_id: the id of the table (obtainable with list_tables)
143+
:param title: the column title
144+
:param column_type: the column type - one of "text", "number", "selection", "datetime", "usergroup"
145+
:param subtype: optional subtype (see above for valid values per type)
146+
:param mandatory: whether this column is required (default false)
147+
:param description: optional description of the column
148+
:return: the created column with its id
149+
"""
150+
payload = {
151+
'tableId': table_id,
152+
'title': title,
153+
'type': column_type,
154+
'mandatory': mandatory,
155+
}
156+
if subtype is not None:
157+
payload['subtype'] = subtype
158+
if description is not None:
159+
payload['description'] = description
160+
if number_prefix is not None:
161+
payload['numberPrefix'] = number_prefix
162+
if number_suffix is not None:
163+
payload['numberSuffix'] = number_suffix
164+
if number_default is not None:
165+
payload['numberDefault'] = number_default
166+
if number_min is not None:
167+
payload['numberMin'] = number_min
168+
if number_max is not None:
169+
payload['numberMax'] = number_max
170+
if number_decimals is not None:
171+
payload['numberDecimals'] = number_decimals
172+
if text_default is not None:
173+
payload['textDefault'] = text_default
174+
if text_max_length is not None:
175+
payload['textMaxLength'] = text_max_length
176+
if selection_options is not None:
177+
payload['selectionOptions'] = selection_options
178+
if selection_default is not None:
179+
payload['selectionDefault'] = selection_default
180+
if datetime_default is not None:
181+
payload['datetimeDefault'] = datetime_default
182+
response = await nc._session._create_adapter().request(
183+
'POST', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables/{table_id}/columns",
184+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
185+
json=payload,
186+
)
187+
return json.dumps(response.json())
188+
189+
@tool
190+
@dangerous_tool
191+
async def update_column(
192+
column_id: int,
193+
title: Optional[str] = None,
194+
mandatory: Optional[bool] = None,
195+
description: Optional[str] = None,
196+
):
197+
"""
198+
Update a column's properties
199+
:param column_id: the id of the column to update (obtainable with list_columns)
200+
:param title: new title for the column
201+
:param mandatory: whether this column is required
202+
:param description: new description for the column
203+
:return: the updated column
204+
"""
205+
payload = {}
206+
if title is not None:
207+
payload['title'] = title
208+
if mandatory is not None:
209+
payload['mandatory'] = mandatory
210+
if description is not None:
211+
payload['description'] = description
212+
response = await nc._session._create_adapter().request(
213+
'PUT', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/columns/{column_id}",
214+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
215+
json=payload,
216+
)
217+
return json.dumps(response.json())
218+
219+
@tool
220+
@dangerous_tool
221+
async def delete_column(column_id: int):
222+
"""
223+
Delete a column from a table. This also removes all data stored in this column for every row.
224+
:param column_id: the id of the column to delete (obtainable with list_columns)
225+
:return: the deleted column
226+
"""
227+
response = await nc._session._create_adapter().request(
228+
'DELETE', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/columns/{column_id}",
229+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
230+
)
231+
return json.dumps(response.json())
232+
233+
# --- Rows ---
234+
235+
@tool
236+
@safe_tool
237+
async def list_rows(table_id: int, limit: Optional[int] = None, offset: Optional[int] = None):
238+
"""
239+
List all rows in a table with their data.
240+
Each row includes its id (needed for update_row/delete_row) and data as column-value pairs.
241+
Use list_columns first to map column IDs to column names.
242+
:param table_id: the id of the table (obtainable with list_tables)
243+
:param limit: maximum number of rows to return
244+
:param offset: number of rows to skip for pagination
245+
:return: list of rows with id, metadata, and data array of {columnId, value} pairs
246+
"""
247+
params = {}
248+
if limit is not None:
249+
params['limit'] = limit
250+
if offset is not None:
251+
params['offset'] = offset
252+
response = await nc._session._create_adapter().request(
253+
'GET', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables/{table_id}/rows",
254+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
255+
params=params,
256+
)
257+
return json.dumps(response.json())
258+
259+
@tool
260+
@dangerous_tool
261+
async def create_row(table_id: int, data: str):
262+
"""
263+
Create a new row in a table.
264+
The data parameter must be a JSON object mapping column IDs to their values.
265+
Use list_columns first to find the column IDs for the target table.
266+
:param table_id: the id of the table (obtainable with list_tables)
267+
:param data: JSON object mapping column IDs to values, e.g. '{"1": "some text", "2": 42, "3": "2026-01-15"}'
268+
:return: the created row
269+
"""
270+
parsed_data = json.loads(data)
271+
response = await nc._session._create_adapter().request(
272+
'POST', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/tables/{table_id}/rows",
273+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
274+
json={'data': parsed_data},
275+
)
276+
return json.dumps(response.json())
277+
278+
@tool
279+
@dangerous_tool
280+
async def update_row(row_id: int, data: str, view_id: Optional[int] = None):
281+
"""
282+
Update an existing row's data.
283+
The data parameter must be a JSON object mapping column IDs to their new values.
284+
Only include columns you want to change.
285+
:param row_id: the id of the row to update (obtainable with list_rows)
286+
:param data: JSON object mapping column IDs to new values, e.g. '{"1": "updated text", "3": "2026-02-20"}'
287+
:param view_id: optional view id for permission context
288+
:return: the updated row
289+
"""
290+
parsed_data = json.loads(data)
291+
payload = {'data': parsed_data}
292+
if view_id is not None:
293+
payload['viewId'] = view_id
294+
response = await nc._session._create_adapter().request(
295+
'PUT', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/rows/{row_id}",
296+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
297+
json=payload,
298+
)
299+
return json.dumps(response.json())
300+
301+
@tool
302+
@dangerous_tool
303+
async def delete_row(row_id: int):
304+
"""
305+
Delete a row from a table
306+
:param row_id: the id of the row to delete (obtainable with list_rows)
307+
:return: the deleted row
308+
"""
309+
response = await nc._session._create_adapter().request(
310+
'DELETE', f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1/rows/{row_id}",
311+
headers={"Content-Type": "application/json", "OCS-APIREQUEST": "true"},
312+
)
313+
return json.dumps(response.json())
314+
315+
return [
316+
list_tables,
317+
create_table,
318+
update_table,
319+
delete_table,
320+
list_columns,
321+
create_column,
322+
update_column,
323+
delete_column,
324+
list_rows,
325+
create_row,
326+
update_row,
327+
delete_row,
328+
]
329+
330+
331+
def get_category_name():
332+
return "Tables"
333+
334+
335+
async def is_available(nc: AsyncNextcloudApp):
336+
return 'tables' in await nc.capabilities

0 commit comments

Comments
 (0)