Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

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

## Unreleased

### Added
- MCP `workflowy_children` tool for paginated, compact, direct-children-only listing.
- `workflowy children` CLI command with `--limit`, `--offset`, `--name-filter`, `--ignore-case`, and `--full`.
- Shared direct-child pagination and compact projection helpers.

## [0.9.0] - Ancestor Retrieval

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

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Restart Claude Desktop and start asking Claude to work with your Workflowy!
|------|-------------|
| `workflowy_get` | Get a node and its descendants as a tree |
| `workflowy_list` | List descendants as a flat list |
| `workflowy_children` | Page through direct children without nested descendants |
| `workflowy_search` | Search nodes by text or regex |
| `workflowy_targets` | List shortcuts and system targets (inbox, etc.) |
| `workflowy_id` | Resolve short ID or target key to full UUID |
Expand Down
50 changes: 50 additions & 0 deletions cmd/workflowy/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func getCommands() []*cli.Command {
return []*cli.Command{
getGetCommand(),
getListCommand(),
getChildrenCommand(),
getCreateCommand(),
getUpdateCommand(),
getMoveCommand(),
Expand Down Expand Up @@ -116,6 +117,55 @@ func getListCommand() *cli.Command {
}
}

func getChildrenCommand() *cli.Command {
return &cli.Command{
Name: "children",
Usage: "List direct children with pagination",
UsageText: "workflowy children [<id>] [options]",
Arguments: getFetchArguments(),
Flags: getChildrenFlags(),
Action: withOptionalClient(func(ctx context.Context, cmd *cli.Command, client workflowy.Client) error {
format := cmd.String("format")
if err := validateFormat(format); err != nil {
return err
}

readGuard, err := NewReadGuard(ctx, client, getReadRootID(cmd))
if err != nil {
return err
}

itemID, err := workflowy.ResolveNodeID(ctx, client, readGuard.DefaultID(cmd.StringArg("id")))
if err != nil {
return fmt.Errorf("cannot resolve ID: %w", err)
}

if err := readGuard.ValidateTarget(itemID, "children"); err != nil {
return err
}

children, err := fetchDirectChildren(cmd, ctx, client, itemID)
if err != nil {
return err
}

page, err := workflowy.NewChildrenPage(children, workflowy.ChildrenPageOptions{
Limit: cmd.Int("limit"),
Offset: cmd.Int("offset"),
Compact: !cmd.Bool("full"),
NameFilter: cmd.String("name-filter"),
IgnoreCase: cmd.Bool("ignore-case"),
})
if err != nil {
return err
}

printOutput(page, format, true)
return nil
}),
}
}

func getCreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Expand Down
69 changes: 69 additions & 0 deletions cmd/workflowy/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,75 @@ func fetchItems(cmd *cli.Command, apiCtx context.Context, client workflowy.Clien
return result, nil
}

func fetchDirectChildren(cmd *cli.Command, apiCtx context.Context, client workflowy.Client, itemID string) ([]*workflowy.Item, error) {
method := cmd.String("method")
backupFile := cmd.String("backup-file")

if method != "" && method != "get" && method != "export" && method != "backup" {
return nil, fmt.Errorf("method must be 'get', 'export', or 'backup'")
}

useMethod := method
if useMethod == "" {
useMethod = "get"
if client == nil {
useMethod = "backup"
}
}
if client == nil && (useMethod == "get" || useMethod == "export") {
return nil, fmt.Errorf("cannot use method '%s' without using the API", useMethod)
}

switch useMethod {
case "get":
resp, err := client.ListChildren(apiCtx, itemID)
if err != nil {
return nil, fmt.Errorf("cannot list children: %w", err)
}
return resp.Items, nil

case "export":
response, err := client.ExportNodesWithCache(apiCtx, cmd.Bool("force-refresh"))
if err != nil {
if method == "" {
slog.Warn("export failed, falling back to backup", "error", err)
return fetchDirectChildrenFromBackup(backupFile, itemID)
}
return nil, fmt.Errorf("cannot export nodes: %w", err)
}
root := workflowy.BuildTreeFromExport(response.Nodes)
return directChildrenFromTree(root.Children, itemID, "export")

case "backup":
return fetchDirectChildrenFromBackup(backupFile, itemID)

default:
return nil, fmt.Errorf("unknown access method: %s", useMethod)
}
}

func fetchDirectChildrenFromBackup(backupFile string, itemID string) ([]*workflowy.Item, error) {
items, err := loadFromBackupProvider(backupFile, workflowy.DefaultBackupProvider)
if err != nil {
return nil, err
}
return directChildrenFromTree(items, itemID, "backup")
}

func directChildrenFromTree(items []*workflowy.Item, itemID string, source string) ([]*workflowy.Item, error) {
if itemID == "None" {
return items, nil
}
found := workflowy.FindItemByID(items, itemID)
if found == nil {
if source == "backup" {
return nil, fmt.Errorf("item %s not found in backup", itemID)
}
return nil, fmt.Errorf("item %s not found", itemID)
}
return found.Children, nil
}

func fetchFromBackup(backupFile string, itemID string, depth int, ancestorOpts ancestorOptions) (interface{}, error) {
items, err := loadFromBackupProvider(backupFile, workflowy.DefaultBackupProvider)
if err != nil {
Expand Down
27 changes: 26 additions & 1 deletion cmd/workflowy/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"

"github.com/mholzen/workflowy/pkg/workflowy"
"github.com/urfave/cli/v3"
)

Expand Down Expand Up @@ -62,6 +63,31 @@ func getFetchFlags() []cli.Flag {
return flags
}

func getChildrenFlags() []cli.Flag {
flags := []cli.Flag{
&cli.IntFlag{
Name: "limit",
Value: workflowy.DefaultChildrenLimit,
Usage: "Maximum number of direct children to return (max 200)",
},
&cli.IntFlag{
Name: "offset",
Usage: "Number of matching direct children to skip before returning results",
},
&cli.BoolFlag{
Name: "full",
Usage: "Return full node fields instead of compact child objects",
},
&cli.StringFlag{
Name: "name-filter",
Usage: "Regular expression matched against direct child names before pagination",
},
getIgnoreCaseFlag(),
}
flags = append(flags, getMethodFlags()...)
return flags
}

func getWriteFlags(commandFlags ...cli.Flag) []cli.Flag {
flags := []cli.Flag{
getAPIKeyFlag(),
Expand Down Expand Up @@ -310,4 +336,3 @@ func getReadRootIdFlag() cli.Flag {
func getReadRootID(cmd *cli.Command) string {
return cmd.String("read-root-id")
}

35 changes: 35 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Complete command-line reference for the Workflowy CLI tool.
- [Available Commands](#available-commands)
- [get](#workflowy-get)
- [list](#workflowy-list)
- [children](#workflowy-children)
- [create](#workflowy-create)
- [update](#workflowy-update)
- [delete](#workflowy-delete)
Expand Down Expand Up @@ -261,6 +262,40 @@ workflowy list --all --format=json

---

### workflowy children

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.

```bash
# First page of root children
workflowy children --format=json

# Page through a large inbox
workflowy children inbox --limit 50 --offset 0 --format=json
workflowy children inbox --limit 50 --offset 50 --format=json

# Filter direct children before pagination
workflowy children <item-id> --name-filter '^A' --ignore-case --format=json

# Return full node metadata, but still omit nested descendants
workflowy children <item-id> --full --format=json
```

**Options:**

| Option | Description | Default |
|--------|-------------|---------|
| `--limit <n>` | Maximum direct children to return (max 200) | `50` |
| `--offset <n>` | Number of matching direct children to skip | `0` |
| `--full` | Return full node fields instead of compact child objects | `false` |
| `--name-filter <regex>` | Regex matched against direct child names before pagination | - |
| `--ignore-case` | Apply `--name-filter` case-insensitively | `false` |
| `--method <get\|export\|backup>` | Data access method | `get` |

The JSON response includes `items`, `total`, `limit`, `offset`, `has_more`, and `next_offset` when another page exists.

---

### workflowy create

Create a new node.
Expand Down
27 changes: 27 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Connect AI assistants like Claude, ChatGPT, and other MCP-compatible clients to
- [Workflowy MCP Tools](#workflowy-mcp-tools)
- [workflowy_get](#workflowy_get)
- [workflowy_list](#workflowy_list)
- [workflowy_children](#workflowy_children)
- [workflowy_search](#workflowy_search)
- [workflowy_targets](#workflowy_targets)
- [workflowy_create](#workflowy_create)
Expand Down Expand Up @@ -165,6 +166,32 @@ List descendants as a flat list.

---

#### workflowy_children

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.

**Parameters:**
| Parameter | Type | Description | Default |
|-----------|------|-------------|---------|
| `id` | string | Parent node ID or target name | root |
| `limit` | number | Maximum direct children to return (max 200) | `50` |
| `offset` | number | Number of matching direct children to skip | `0` |
| `compact` | boolean | Return compact child objects | `true` |
| `name_filter` | string | Optional regex matched against direct child names before pagination | - |
| `ignore_case` | boolean | Apply `name_filter` case-insensitively | `false` |
| `method` | string | Access method: get, export, or backup | get |

**Returns:**
- `items`: Direct children only. Compact items include `id`, `name`, `layoutMode` when present, `completed`, and `has_children` when known.
- `total`: Count of matching direct children before pagination.
- `limit`, `offset`: Page parameters used.
- `has_more`: Whether another page exists.
- `next_offset`: Offset for the next page, omitted on the last page.

**Example prompt:** "List the first 50 direct children of my Dropbox node, then continue with the next page."

---

#### workflowy_search

Search nodes by text or regex pattern. Completed nodes (and their descendants) are excluded by default.
Expand Down
57 changes: 57 additions & 0 deletions docs/plans/2026-06-26-direct-children-pagination-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Design: paginated direct children for MCP

## Problem

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.

## Decision

Add a dedicated `workflowy_children` MCP tool instead of changing `workflowy_list`.

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.

## Behavior

- Default access method is the live direct-children API: `GET /api/v1/nodes?parent_id=<id>`.
- `method=export` and `method=backup` are available as explicit fallbacks.
- Results are sorted by `priority` ascending, then `id` ascending before pagination.
- `name_filter` is a regular expression applied to direct child names before pagination.
- `limit` defaults to 50 and is capped at 200 to keep tool responses bounded.
- `offset` is zero-based.
- The response includes `total`, `limit`, `offset`, `has_more`, and `next_offset` when another page exists.

## Compact output

The default compact projection returns:

- `id`
- `name`
- `layoutMode` when present
- `completed`
- `has_children` when the source data contains child information

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.

## Response example

```json
{
"items": [
{
"id": "3495d784-5db2-408f-8c4a-7ae1be810d4f",
"name": "Triage this",
"layoutMode": "todo",
"completed": false
}
],
"total": 1000,
"limit": 50,
"offset": 0,
"next_offset": 50,
"has_more": true
}
```

## CLI parity

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`.
15 changes: 15 additions & 0 deletions docs/plans/2026-06-26-direct-children-pagination-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Direct Children Pagination Implementation Plan

## Goal

Expose a bounded direct-children listing surface for large Workflowy nodes.

## Tasks

1. Add shared child pagination/projection helpers in `pkg/workflowy`.
2. Add `workflowy_children` MCP tool with `id`, `limit`, `offset`, `compact`, `name_filter`, `ignore_case`, and `method`.
3. Register the tool in `--expose=read`, `--expose=all`, and `--expose=children`.
4. Add `workflowy children` CLI parity.
5. Add focused unit tests for stable ordering, pagination boundaries, compact projection, full projection, and name filtering.
6. Update MCP, CLI, README, and changelog documentation.
7. Run Go tests when Go tooling is available.
3 changes: 3 additions & 0 deletions pkg/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ var (
allTools = []string{
ToolGet,
ToolList,
ToolChildren,
ToolSearch,
ToolTargets,
ToolID,
Expand All @@ -181,6 +182,7 @@ var (
readTools = []string{
ToolGet,
ToolList,
ToolChildren,
ToolSearch,
ToolTargets,
ToolID,
Expand Down Expand Up @@ -211,6 +213,7 @@ var (
aliasMap = map[string]string{
"get": ToolGet,
"list": ToolList,
"children": ToolChildren,
"search": ToolSearch,
"targets": ToolTargets,
"id": ToolID,
Expand Down
Loading