Skip to content

Commit 112e0f4

Browse files
Mark VletterMark Vletter
authored andcommitted
feat: add paginated children listing
1 parent 86e8cd5 commit 112e0f4

13 files changed

Lines changed: 675 additions & 2 deletions

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## Unreleased
6+
7+
### Added
8+
- MCP `workflowy_children` tool for paginated, compact, direct-children-only listing.
9+
- `workflowy children` CLI command with `--limit`, `--offset`, `--name-filter`, `--ignore-case`, and `--full`.
10+
- Shared direct-child pagination and compact projection helpers.
11+
512
## [0.9.0] - Ancestor Retrieval
613

714
### Added
@@ -189,4 +196,3 @@ All notable changes to this project will be documented in this file.
189196
### Changed
190197
- Unified client creation code to single function
191198
- Unified error and log messaging for consistency
192-

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Restart Claude Desktop and start asking Claude to work with your Workflowy!
9090
|------|-------------|
9191
| `workflowy_get` | Get a node and its descendants as a tree |
9292
| `workflowy_list` | List descendants as a flat list |
93+
| `workflowy_children` | Page through direct children without nested descendants |
9394
| `workflowy_search` | Search nodes by text or regex |
9495
| `workflowy_targets` | List shortcuts and system targets (inbox, etc.) |
9596
| `workflowy_id` | Resolve short ID or target key to full UUID |

cmd/workflowy/commands.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func getCommands() []*cli.Command {
2121
return []*cli.Command{
2222
getGetCommand(),
2323
getListCommand(),
24+
getChildrenCommand(),
2425
getCreateCommand(),
2526
getUpdateCommand(),
2627
getMoveCommand(),
@@ -116,6 +117,55 @@ func getListCommand() *cli.Command {
116117
}
117118
}
118119

120+
func getChildrenCommand() *cli.Command {
121+
return &cli.Command{
122+
Name: "children",
123+
Usage: "List direct children with pagination",
124+
UsageText: "workflowy children [<id>] [options]",
125+
Arguments: getFetchArguments(),
126+
Flags: getChildrenFlags(),
127+
Action: withOptionalClient(func(ctx context.Context, cmd *cli.Command, client workflowy.Client) error {
128+
format := cmd.String("format")
129+
if err := validateFormat(format); err != nil {
130+
return err
131+
}
132+
133+
readGuard, err := NewReadGuard(ctx, client, getReadRootID(cmd))
134+
if err != nil {
135+
return err
136+
}
137+
138+
itemID, err := workflowy.ResolveNodeID(ctx, client, readGuard.DefaultID(cmd.StringArg("id")))
139+
if err != nil {
140+
return fmt.Errorf("cannot resolve ID: %w", err)
141+
}
142+
143+
if err := readGuard.ValidateTarget(itemID, "children"); err != nil {
144+
return err
145+
}
146+
147+
children, err := fetchDirectChildren(cmd, ctx, client, itemID)
148+
if err != nil {
149+
return err
150+
}
151+
152+
page, err := workflowy.NewChildrenPage(children, workflowy.ChildrenPageOptions{
153+
Limit: cmd.Int("limit"),
154+
Offset: cmd.Int("offset"),
155+
Compact: !cmd.Bool("full"),
156+
NameFilter: cmd.String("name-filter"),
157+
IgnoreCase: cmd.Bool("ignore-case"),
158+
})
159+
if err != nil {
160+
return err
161+
}
162+
163+
printOutput(page, format, true)
164+
return nil
165+
}),
166+
}
167+
}
168+
119169
func getCreateCommand() *cli.Command {
120170
return &cli.Command{
121171
Name: "create",

cmd/workflowy/fetch.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,75 @@ func fetchItems(cmd *cli.Command, apiCtx context.Context, client workflowy.Clien
182182
return result, nil
183183
}
184184

185+
func fetchDirectChildren(cmd *cli.Command, apiCtx context.Context, client workflowy.Client, itemID string) ([]*workflowy.Item, error) {
186+
method := cmd.String("method")
187+
backupFile := cmd.String("backup-file")
188+
189+
if method != "" && method != "get" && method != "export" && method != "backup" {
190+
return nil, fmt.Errorf("method must be 'get', 'export', or 'backup'")
191+
}
192+
193+
useMethod := method
194+
if useMethod == "" {
195+
useMethod = "get"
196+
if client == nil {
197+
useMethod = "backup"
198+
}
199+
}
200+
if client == nil && (useMethod == "get" || useMethod == "export") {
201+
return nil, fmt.Errorf("cannot use method '%s' without using the API", useMethod)
202+
}
203+
204+
switch useMethod {
205+
case "get":
206+
resp, err := client.ListChildren(apiCtx, itemID)
207+
if err != nil {
208+
return nil, fmt.Errorf("cannot list children: %w", err)
209+
}
210+
return resp.Items, nil
211+
212+
case "export":
213+
response, err := client.ExportNodesWithCache(apiCtx, cmd.Bool("force-refresh"))
214+
if err != nil {
215+
if method == "" {
216+
slog.Warn("export failed, falling back to backup", "error", err)
217+
return fetchDirectChildrenFromBackup(backupFile, itemID)
218+
}
219+
return nil, fmt.Errorf("cannot export nodes: %w", err)
220+
}
221+
root := workflowy.BuildTreeFromExport(response.Nodes)
222+
return directChildrenFromTree(root.Children, itemID, "export")
223+
224+
case "backup":
225+
return fetchDirectChildrenFromBackup(backupFile, itemID)
226+
227+
default:
228+
return nil, fmt.Errorf("unknown access method: %s", useMethod)
229+
}
230+
}
231+
232+
func fetchDirectChildrenFromBackup(backupFile string, itemID string) ([]*workflowy.Item, error) {
233+
items, err := loadFromBackupProvider(backupFile, workflowy.DefaultBackupProvider)
234+
if err != nil {
235+
return nil, err
236+
}
237+
return directChildrenFromTree(items, itemID, "backup")
238+
}
239+
240+
func directChildrenFromTree(items []*workflowy.Item, itemID string, source string) ([]*workflowy.Item, error) {
241+
if itemID == "None" {
242+
return items, nil
243+
}
244+
found := workflowy.FindItemByID(items, itemID)
245+
if found == nil {
246+
if source == "backup" {
247+
return nil, fmt.Errorf("item %s not found in backup", itemID)
248+
}
249+
return nil, fmt.Errorf("item %s not found", itemID)
250+
}
251+
return found.Children, nil
252+
}
253+
185254
func fetchFromBackup(backupFile string, itemID string, depth int, ancestorOpts ancestorOptions) (interface{}, error) {
186255
items, err := loadFromBackupProvider(backupFile, workflowy.DefaultBackupProvider)
187256
if err != nil {

cmd/workflowy/flags.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88

9+
"github.com/mholzen/workflowy/pkg/workflowy"
910
"github.com/urfave/cli/v3"
1011
)
1112

@@ -62,6 +63,31 @@ func getFetchFlags() []cli.Flag {
6263
return flags
6364
}
6465

66+
func getChildrenFlags() []cli.Flag {
67+
flags := []cli.Flag{
68+
&cli.IntFlag{
69+
Name: "limit",
70+
Value: workflowy.DefaultChildrenLimit,
71+
Usage: "Maximum number of direct children to return (max 200)",
72+
},
73+
&cli.IntFlag{
74+
Name: "offset",
75+
Usage: "Number of matching direct children to skip before returning results",
76+
},
77+
&cli.BoolFlag{
78+
Name: "full",
79+
Usage: "Return full node fields instead of compact child objects",
80+
},
81+
&cli.StringFlag{
82+
Name: "name-filter",
83+
Usage: "Regular expression matched against direct child names before pagination",
84+
},
85+
getIgnoreCaseFlag(),
86+
}
87+
flags = append(flags, getMethodFlags()...)
88+
return flags
89+
}
90+
6591
func getWriteFlags(commandFlags ...cli.Flag) []cli.Flag {
6692
flags := []cli.Flag{
6793
getAPIKeyFlag(),
@@ -310,4 +336,3 @@ func getReadRootIdFlag() cli.Flag {
310336
func getReadRootID(cmd *cli.Command) string {
311337
return cmd.String("read-root-id")
312338
}
313-

docs/CLI.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Complete command-line reference for the Workflowy CLI tool.
1515
- [Available Commands](#available-commands)
1616
- [get](#workflowy-get)
1717
- [list](#workflowy-list)
18+
- [children](#workflowy-children)
1819
- [create](#workflowy-create)
1920
- [update](#workflowy-update)
2021
- [delete](#workflowy-delete)
@@ -261,6 +262,40 @@ workflowy list --all --format=json
261262

262263
---
263264

265+
### workflowy children
266+
267+
List only direct children of a node in stable outline order, with pagination. This command is intended for large nodes where `get` or `list` would return too much data.
268+
269+
```bash
270+
# First page of root children
271+
workflowy children --format=json
272+
273+
# Page through a large inbox
274+
workflowy children inbox --limit 50 --offset 0 --format=json
275+
workflowy children inbox --limit 50 --offset 50 --format=json
276+
277+
# Filter direct children before pagination
278+
workflowy children <item-id> --name-filter '^A' --ignore-case --format=json
279+
280+
# Return full node metadata, but still omit nested descendants
281+
workflowy children <item-id> --full --format=json
282+
```
283+
284+
**Options:**
285+
286+
| Option | Description | Default |
287+
|--------|-------------|---------|
288+
| `--limit <n>` | Maximum direct children to return (max 200) | `50` |
289+
| `--offset <n>` | Number of matching direct children to skip | `0` |
290+
| `--full` | Return full node fields instead of compact child objects | `false` |
291+
| `--name-filter <regex>` | Regex matched against direct child names before pagination | - |
292+
| `--ignore-case` | Apply `--name-filter` case-insensitively | `false` |
293+
| `--method <get\|export\|backup>` | Data access method | `get` |
294+
295+
The JSON response includes `items`, `total`, `limit`, `offset`, `has_more`, and `next_offset` when another page exists.
296+
297+
---
298+
264299
### workflowy create
265300

266301
Create a new node.

docs/MCP.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Connect AI assistants like Claude, ChatGPT, and other MCP-compatible clients to
1111
- [Workflowy MCP Tools](#workflowy-mcp-tools)
1212
- [workflowy_get](#workflowy_get)
1313
- [workflowy_list](#workflowy_list)
14+
- [workflowy_children](#workflowy_children)
1415
- [workflowy_search](#workflowy_search)
1516
- [workflowy_targets](#workflowy_targets)
1617
- [workflowy_create](#workflowy_create)
@@ -165,6 +166,32 @@ List descendants as a flat list.
165166

166167
---
167168

169+
#### workflowy_children
170+
171+
List only the direct children of a node with stable ordering and pagination. This is the recommended tool for triaging large inboxes or folders because it does not include nested descendants.
172+
173+
**Parameters:**
174+
| Parameter | Type | Description | Default |
175+
|-----------|------|-------------|---------|
176+
| `id` | string | Parent node ID or target name | root |
177+
| `limit` | number | Maximum direct children to return (max 200) | `50` |
178+
| `offset` | number | Number of matching direct children to skip | `0` |
179+
| `compact` | boolean | Return compact child objects | `true` |
180+
| `name_filter` | string | Optional regex matched against direct child names before pagination | - |
181+
| `ignore_case` | boolean | Apply `name_filter` case-insensitively | `false` |
182+
| `method` | string | Access method: get, export, or backup | get |
183+
184+
**Returns:**
185+
- `items`: Direct children only. Compact items include `id`, `name`, `layoutMode` when present, `completed`, and `has_children` when known.
186+
- `total`: Count of matching direct children before pagination.
187+
- `limit`, `offset`: Page parameters used.
188+
- `has_more`: Whether another page exists.
189+
- `next_offset`: Offset for the next page, omitted on the last page.
190+
191+
**Example prompt:** "List the first 50 direct children of my Dropbox node, then continue with the next page."
192+
193+
---
194+
168195
#### workflowy_search
169196

170197
Search nodes by text or regex pattern. Completed nodes (and their descendants) are excluded by default.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Design: paginated direct children for MCP
2+
3+
## Problem
4+
5+
The existing MCP read tools return a node subtree, flattened descendants, or recursive search results. Large nodes can produce hundreds of kilobytes or more of JSON, which makes it impossible for an AI assistant to enumerate and triage direct children in bounded batches.
6+
7+
## Decision
8+
9+
Add a dedicated `workflowy_children` MCP tool instead of changing `workflowy_list`.
10+
11+
This is the lowest-risk option because existing tool schemas and default output shapes remain unchanged. The new tool has one focused contract: list only direct children of a parent, sort them into stable outline order, then apply filtering and pagination.
12+
13+
## Behavior
14+
15+
- Default access method is the live direct-children API: `GET /api/v1/nodes?parent_id=<id>`.
16+
- `method=export` and `method=backup` are available as explicit fallbacks.
17+
- Results are sorted by `priority` ascending, then `id` ascending before pagination.
18+
- `name_filter` is a regular expression applied to direct child names before pagination.
19+
- `limit` defaults to 50 and is capped at 200 to keep tool responses bounded.
20+
- `offset` is zero-based.
21+
- The response includes `total`, `limit`, `offset`, `has_more`, and `next_offset` when another page exists.
22+
23+
## Compact output
24+
25+
The default compact projection returns:
26+
27+
- `id`
28+
- `name`
29+
- `layoutMode` when present
30+
- `completed`
31+
- `has_children` when the source data contains child information
32+
33+
The live direct-children API does not expose child counts, so `has_children` is omitted for live API results instead of returning a misleading `false`. Backup/export sources include this field when children were present in the loaded tree.
34+
35+
## Response example
36+
37+
```json
38+
{
39+
"items": [
40+
{
41+
"id": "3495d784-5db2-408f-8c4a-7ae1be810d4f",
42+
"name": "Triage this",
43+
"layoutMode": "todo",
44+
"completed": false
45+
}
46+
],
47+
"total": 1000,
48+
"limit": 50,
49+
"offset": 0,
50+
"next_offset": 50,
51+
"has_more": true
52+
}
53+
```
54+
55+
## CLI parity
56+
57+
Add `workflowy children [<id>]` with the same pagination and filter behavior. The CLI is useful for manual verification and mirrors the MCP capability without changing `get` or `list`.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Direct Children Pagination Implementation Plan
2+
3+
## Goal
4+
5+
Expose a bounded direct-children listing surface for large Workflowy nodes.
6+
7+
## Tasks
8+
9+
1. Add shared child pagination/projection helpers in `pkg/workflowy`.
10+
2. Add `workflowy_children` MCP tool with `id`, `limit`, `offset`, `compact`, `name_filter`, `ignore_case`, and `method`.
11+
3. Register the tool in `--expose=read`, `--expose=all`, and `--expose=children`.
12+
4. Add `workflowy children` CLI parity.
13+
5. Add focused unit tests for stable ordering, pagination boundaries, compact projection, full projection, and name filtering.
14+
6. Update MCP, CLI, README, and changelog documentation.
15+
7. Run Go tests when Go tooling is available.

pkg/mcp/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ var (
160160
allTools = []string{
161161
ToolGet,
162162
ToolList,
163+
ToolChildren,
163164
ToolSearch,
164165
ToolTargets,
165166
ToolID,
@@ -181,6 +182,7 @@ var (
181182
readTools = []string{
182183
ToolGet,
183184
ToolList,
185+
ToolChildren,
184186
ToolSearch,
185187
ToolTargets,
186188
ToolID,
@@ -211,6 +213,7 @@ var (
211213
aliasMap = map[string]string{
212214
"get": ToolGet,
213215
"list": ToolList,
216+
"children": ToolChildren,
214217
"search": ToolSearch,
215218
"targets": ToolTargets,
216219
"id": ToolID,

0 commit comments

Comments
 (0)