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:
searchorcontextfinds a starting set.expand_neighborsorfetch_slicewidens it.- 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 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
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
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
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 spansfetch_slice
Input
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
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
fetch_slice("passage_9f2c1b") # window centered on this span
fetch_slice("decision_abc", window=5) # first five spans of this memoryBounds 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 for the shared search surface.
