---
url: /sibyl/api/mcp-traverse.md
---
# MCP Tools: expand\_neighbors and fetch\_slice

Bounded traversal verbs for an agent that steers its own retrieval. `expand_neighbors` widens a set
of known memories into their graph neighborhood; `fetch_slice` widens one memory into the span
window it was cut into.

## The traversal contract

These verbs are steps in a loop of **at most three rounds**:

1. `search` or `context` finds a starting set.
2. `expand_neighbors` or `fetch_slice` widens it.
3. One more widening round, then answer.

Two retrieval iterations capture nearly all of the available gain, so a fourth round costs latency
and buys noise. The verbs are stateless and bounded per call; the round budget lives in the tool
docstrings rather than in server state, because there is no session to enforce it in.

**Skip traversal entirely when one hop answers the question.** For "what do we know about X", call
[`context`](./mcp-context.md) and read the pack it composed. Traversal is for when you hold specific
memories and need what sits next to them: the tasks blocking this one, the decision a plan
supersedes, the spans of a memory a search only matched part of.

**Composition is not yours.** Both verbs return adjacency and previews so you can choose what to
gather. `context` still renders the evidence and keeps control of ordering and the reserved note
lane.

## Authorization

Every row either verb returns is authorized for the calling reader individually, not once per seed.
Project membership does not authorize a private memory that happens to sit in that project, so a
neighbor missing from a result may exist and belong to someone else.

The check gates the walk, not only its output. Only authorized rows join the next frontier, so an
unreadable memory is not a route either: you will not receive a two-hop neighbor that is reachable
only through someone else's private memory, because receiving it would tell you something sits
between you and it.

A span is never more readable than the memory it was cut from. `fetch_slice` authorizes the parent
before serving a span, even when you name the span directly, because a span inherits its parent's
scope once at write time and a later scope-only edit does not refresh it.

A seed that is absent and a seed the reader may not see are reported identically, and `fetch_slice`
raises the same 404 for a denied entity as for one that does not exist. Distinguishing them would
confirm the existence of a row the caller has no right to know about.

## expand\_neighbors

### Input

```typescript
interface ExpandNeighborsInput {
  entity_ids: string[]; // Seeds, at most 8
  relationship_types?: string[]; // Restrict hops, e.g. ["DEPENDS_ON"]
  types?: string[]; // Restrict the neighbors returned to these entity types
  depth?: number; // 1-3, default 1
  limit?: number; // 1-24, default 8
  content_max_chars?: number; // Preview characters per neighbor, default 500
  include_incoming?: boolean; // default true
  project?: string; // Scope the walk to one readable project
}
```

`include_incoming` defaults to true because the interesting neighbors are usually on that side. The
graph writes edges from span to memory and from dependent to dependency, so an outgoing-only walk
reports those neighbors as absent.

`types` narrows the answer, not the walk. A two-hop neighbor of the requested type stays reachable
through a first hop of some other type, so asking for decisions will not hide a decision that sits
behind a task.

`covers_parent` reads a flag that only the prose passage projection writes, so spans cut from
operational evidence report `false` even when the window is complete. That is conservative in the
safe direction: it never claims coverage it does not have.

`direction` describes the edge that reached a row from the node one hop closer, not from your seed.
At `distance` 1 those are the same thing; at `distance` 2 and beyond, an `incoming` row points at
its own predecessor in the walk rather than at the seed you named.

### Output

```typescript
interface ExpandNeighborsOutput {
  origins: string[]; // Seeds that resolved for this reader
  unresolved: string[]; // Seeds that did not, without saying why
  neighbors: {
    id: string;
    type: string;
    name: string;
    relationship: string; // Edge name, or SHARES_COMMUNITY / MENTIONS
    direction: "outgoing" | "incoming";
    distance: number; // Hop count from the nearest seed
    score: number; // Relationship weight with depth decay
    content: string; // Preview, bounded by content_max_chars
    project_id: string | null;
    metadata: Record<string, unknown>;
  }[];
  total: number;
  depth: number;
  limit: number;
  truncated: boolean; // More neighbors existed than the limit returned
  filters: Record<string, unknown>;
}
```

A neighbor that is itself a span carries `passage_index`, `parent_entity_id`, and
`widen_with: "fetch_slice"`, naming the next move rather than making the caller infer it.

### Examples

```python
expand_neighbors(["task_abc"], relationship_types=["DEPENDS_ON"])
expand_neighbors(["decision_1", "decision_2"], depth=2, limit=16)
expand_neighbors(["artifact_abc"], relationship_types=["PART_OF"])  # its spans
```

## fetch\_slice

### Input

```typescript
interface FetchSliceInput {
  entity_id: string; // A passage ID, or the memory it was cut from
  window?: number; // 1-64, default 3
  content_max_chars?: number; // Budget for the whole window, default 18000
  project?: string; // Scope the read to one readable project
}
```

The default window of three is the measured adjacency: three adjacent spans reach the same answer
exposure as sending the whole memory, where a lone span reaches noticeably less.

Given a passage, the window centers on it. Given a memory, the window starts at its first span.

### Output

```typescript
interface FetchSliceOutput {
  entity_id: string; // What was asked for
  parent_id: string; // What a citation resolves to
  parent_name: string;
  parent_type: string;
  passages: {
    id: string;
    name: string;
    content: string;
    passage_index: number | null;
    passage_total: number | null;
    breadcrumb: string | null;
    truncated: boolean;
  }[];
  window: number;
  sliced: boolean; // false when the memory was never cut into spans
  total: number;
  window_start: number | null;
  passage_total: number | null;
  covers_parent: boolean; // Whether these spans account for the whole body
  project_id: string | null;
  content_chars: number;
}
```

**Cite `parent_id`, not a span id.** Spans are re-minted whenever their memory is edited, so a
citation pointing at one goes stale silently. `covers_parent` says whether the returned spans
account for the whole body; when it is false, text lives only on the parent.

A memory short enough never to have been cut comes back whole with `sliced: false`. That is the
answer, not an error to retry.

### Examples

```python
fetch_slice("passage_9f2c1b")            # window centered on this span
fetch_slice("decision_abc", window=5)    # first five spans of this memory
```

## Bounds are clamped, not rejected

Both surfaces clamp out-of-range budgets rather than refusing them, and every response reports the
value it actually applied (`depth`, `limit`, `window`). A tool signature cannot express a range, so
the MCP verbs clamp; rejecting on the REST side would mean the identical request succeeds for an
agent and fails for a script. Seeds past the cap come back in `unresolved` rather than being walked
silently. Malformed input, such as a blank `entity_id`, is a `400` and is a different thing from an
out-of-range budget.

## REST equivalents

| Verb               | Endpoint                  |
| ------------------ | ------------------------- |
| `expand_neighbors` | `POST /api/search/expand` |
| `fetch_slice`      | `POST /api/search/slice`  |

Both accept the same fields as the MCP tools and resolve the caller's readable projects from their
session before the walk. See [rest-search.md](./rest-search.md) for the shared search surface.
