mcp-rune 0.107.0
Be star #1 Get started
SECTION IX · GUIDE 38 OF 49
Reading
5 min
Topic
retrieval · strategy
Spec
v0.107.0
Source
09-retrieval-and-graphrag/summary-strategies.md
On this page6 sections

Summary Strategies

mcp-rune’s analysis_ingest and analysis_summarize tools persist a page summary for every batch of records they process. The summary is embedded and recallable via analysis_query mode: semantic — it’s the LLM’s “starter pack” before any qualitative findings have been written.

A summary strategy decides what that page summary contains. Different agents want different lenses: a data-quality audit wants coverage and anomalies; a relationship-mapping task wants entity crosswalks; a temporal investigation wants cadence and gaps. Rather than hard-code one shape, mcp-rune ships a small interface, nine built-ins, and an extension point so hosts can register their own.

This guide covers:

For the broader analysis-tool family, see Analysis Memories Guide. For how to register your strategy through an ApiExtension, see API Extensions.

The nine built-ins split into two families based on what extra data the dispatcher loads for them:

9 SUMMARY STRATEGIES · TWO FAMILIESField-level (5)records onlydistributioncoverageanomalytemporalentity-extractionalways runs · or simple shape gateGraphRAG-aware (4)records + auxiliaryrelationship-coveragerequires: edgesconcept-touchrequires: edges + domainrule-violationrequires: domainsemantic-clusterrequires: embeddingsonly when the dispatcher can load the required auxiliary data

A field-level strategy works on any page with records. A GraphRAG-aware strategy needs the auxiliary side-channel populated by analysis_ingestedges from multi-hop ingest, embeddings from embed_records: true, or domainRegistry passed at server boot. Each strategy declares its needs via requires; the dispatcher loads them lazily and silently skips strategies whose requirements aren’t met. See the per-strategy guides below for input/algorithm/output diagrams.

The contract

A strategy is a deterministic pure function over a page of records.

src/summary-input.ts
export interface SummaryEdge {
  src_id: string
  dst_model: string
  dst_id: string
  edge_type: string
}

export type SummaryRequirement = 'edges' | 'embeddings' | 'domainRegistry'

export interface SummaryConcept {
  readonly name: string
  readonly title?: string
  readonly description?: string
  readonly models: ReadonlyArray<string>
}

export interface SummaryRule {
  readonly name: string
  readonly description?: string
  readonly scope: ReadonlyArray<string>
  readonly severity?: 'error' | 'warning' | 'info'
  evaluate(
    data: Record<string, unknown>,
    context?: Record<string, unknown>
  ): { passed: boolean; message?: string } | Promise<{ passed: boolean; message?: string }>
}

// Loosely-typed view of the DomainRegistry, so strategies stay decoupled
// from the domain module.
export interface SummaryDomainRegistry {
  knowledge?: { getConceptsForModel?: (model: string) => ReadonlyArray<SummaryConcept> }
  rules?: { getRulesForModel?: (model: string) => ReadonlyArray<SummaryRule> }
}

export interface SummaryInput {
  analysisId: string
  model: string
  page: number
  totalPages: number | null
  records: ReadonlyArray<Record<string, unknown>>
  fields?: ReadonlyArray<string>
  options?: Readonly<Record<string, unknown>>
  edges?: ReadonlyArray<SummaryEdge> // populated only for `requires: ['edges', …]`
  embeddings?: ReadonlyMap<string, Float32Array> // record-id → 384-dim vector; only for `requires: ['embeddings', …]`
  domainRegistry?: SummaryDomainRegistry // concepts + rules; only for `requires: ['domainRegistry', …]`
}

export interface SummaryOutput {
  finding: string // embedded as the memory row text
  metadata: Record<string, unknown> // stored alongside
  category?: string // defaults to `page_summary:<strategy.name>`
}

export interface SummaryStrategy {
  readonly name: string // lowercase kebab-case, globally unique
  readonly description: string // one-line LLM-facing hint
  readonly requires?: ReadonlyArray<SummaryRequirement> // auxiliary data the dispatcher loads per page
  appliesTo?(input: SummaryInput): boolean // optional gate; omit = always applies
  generate(input: SummaryInput): SummaryOutput | Promise<SummaryOutput>
}
/**
 * One typed edge originating from a record on the page.
 *
 * @typedef {Object} SummaryEdge
 * @property {string} src_id
 * @property {string} dst_model
 * @property {string} dst_id
 * @property {string} edge_type
 */

/**
 * Auxiliary data a strategy can declare via `requires`.
 *
 * @typedef {'edges' | 'embeddings' | 'domainRegistry'} SummaryRequirement
 */

/**
 * What the framework hands to a SummaryStrategy when it asks for a
 * per-page summary during `analysis_ingest`.
 *
 * @typedef {Object} SummaryInput
 * @property {string} analysisId
 * @property {string} model
 * @property {number} page
 * @property {number | null} totalPages
 * @property {ReadonlyArray<Object>} records
 * @property {ReadonlyArray<string>} [fields]
 * @property {Readonly<Object>} [options]
 * @property {ReadonlyArray<SummaryEdge>} [edges]           populated only for `requires: ['edges', …]`
 * @property {ReadonlyMap<string, Float32Array>} [embeddings]  record-id → 384-dim vector; only for `requires: ['embeddings', …]`
 * @property {Object} [domainRegistry]                      concepts + rules; only for `requires: ['domainRegistry', …]`
 */

/**
 * What a SummaryStrategy returns. `finding` is embedded as the memory
 * row's searchable text; `metadata` is stored alongside.
 *
 * @typedef {Object} SummaryOutput
 * @property {string} finding
 * @property {Object} metadata
 * @property {string} [category]   defaults to `page_summary:<strategy.name>`
 */

/**
 * A pluggable strategy contributed via an ApiExtension. Globally
 * registered (single `name` namespace); strategies opt-in to specific
 * pages via `appliesTo`.
 *
 * @typedef {Object} SummaryStrategy
 * @property {string} name                       lowercase kebab-case, globally unique
 * @property {string} description                one-line LLM-facing hint
 * @property {ReadonlyArray<SummaryRequirement>} [requires]  auxiliary data the dispatcher loads per page
 * @property {(input: SummaryInput) => boolean} [appliesTo]  optional gate; omit = always applies
 * @property {(input: SummaryInput) => SummaryOutput | Promise<SummaryOutput>} generate
 */

Guarantees the framework gives you:

  • Strategies are deterministic — same input, same output. No LLM calls inside generate(), no network, no clock-dependent behavior. (If you need probabilistic output, take a seed via options.)
  • records is the already-flattened, field-picked array — what the LLM would see if it queried analysis_query mode: sample over the same page.
  • appliesTo(input) returning false silently skips the strategy: no memory is stored, no error is raised. Use it to gate on data-shape preconditions (e.g. “I need at least one ISO-date field”). Omitting appliesTo means the strategy always applies.
  • Names are globally unique across built-ins and all extensions. Collisions throw at boot with both owner keys in the error message.

What strategies must not do:

  • Call an LLM, fetch over the network, read from disk, or talk to a database. The strategy runs inside the analysis_ingest page loop and inside analysis_summarize — both contexts expect fast, deterministic work.
  • Mutate the input records. records is typed ReadonlyArray; treat it that way.
  • Throw to signal “doesn’t apply”. Use appliesTo for that. Throwing aborts the page’s ingest and surfaces to the LLM as a tool error.

Built-ins

Nine strategies ship with the framework. distribution is the default; the rest are picked by the LLM via summary_strategy / summary_strategies, or run after the fact via analysis_summarize. The four GraphRAG-aware strategies (relationship-coverage, concept-touch, rule-violation, semantic-cluster) require auxiliary data the dispatcher loads lazily based on each strategy’s requires declaration.

Field-level strategies

NameAlways applies?RequiresGuide
distributionYesPer-field value distributions, numeric stats, date ranges.
coverageYesNull/empty rate per field; flags fields above 50% missing.
anomaly≥ 4 recordsNumeric z-score outliers and rare enum values.
temporal≥ 1 ISO-date fieldTime-bucketed counts, gap detection, recency.
entity-extraction≥ 1 *_id fieldTop-N references per *_id field.

GraphRAG-aware strategies

These read from the relationship graph and embeddings that analysis_ingest populates when run with hop_depth ≥ 1 and embed_records: true.

NameRequiresGuide
relationship-coverage['edges']Per-edge-type coverage %, degree stats, gap-records list.
concept-touch['edges', 'domainRegistry']Per-concept participation %, per-target-model breakdown.
rule-violation['domainRegistry']Per-BusinessRule pass/fail counts + failing IDs.
semantic-cluster['embeddings']Anchor-nearest clustering, sizes + representatives.

Each strategy’s description is what the LLM sees when picking — they’re tuned for one-shot disambiguation. The per-strategy guides above include real bookshelf output and edge-case notes; the Analysis Quickstart walks all nine end to end against a runnable example.

If you find the LLM consistently picking the wrong strategy for your domain, ship a custom one with a sharper description.

Choosing a strategy at call time

Two LLM-facing surfaces let the model pick. For a worked Inspector recipe per built-in strategy against a 5,000-record dataset, see the Analysis Quickstart.

At ingest time

analysis_ingest({
  model: "scheduling",
  analysis_id: "q1-audit",
  ingest_all: true,
  summary_strategy: "anomaly"      // or omit for the "distribution" default
})

Or run several lenses per page in one call:

analysis_ingest({
  model: "scheduling",
  analysis_id: "q1-audit",
  ingest_all: true,
  summary_strategies: ["distribution", "anomaly", "temporal"]
})

Each strategy that passes its appliesTo check writes one memory per page. If you pass both summary_strategy and summary_strategies you get a validation error — they’re mutually exclusive.

After the fact (no refetch)

analysis_summarize({
  analysis_id: "q1-audit",
  strategies: ["anomaly", "entity-extraction"],
  where: { status: "invalid" },     // optional — scope to a subset
  max_records: 500
})

Use this when you ingested with the default distribution, looked at the results, and now want a different lens over data already in storage. No API round-trip, just a fresh strategy pass over the existing rows.

Authoring a custom strategy

src/count-by.ts
import type {
  SummaryStrategy,
  SummaryInput,
  SummaryOutput
} from '@mcp-rune/mcp-rune/api-extensions'

export const dealNarrativeStrategy: SummaryStrategy = {
  name: 'deal-narrative',
  description:
    'One-paragraph prose summary of a deal page: pipeline mix, total dollar value, ' +
    'top owners. Pick this when preparing a status report.',

  // Only run when the page looks like deals.
  appliesTo(input: SummaryInput): boolean {
    return input.model === 'deal' && input.records.length > 0
  },

  generate(input: SummaryInput): SummaryOutput {
    const total = input.records.length
    const totalValue = input.records.reduce(
      (acc, r) => acc + (typeof r.amount === 'number' ? r.amount : 0),
      0
    )
    const byStage = countBy(input.records, 'stage')
    const topStage = topEntry(byStage)
    const ownerCounts = countBy(input.records, 'owner_id')
    const topOwner = topEntry(ownerCounts)

    return {
      finding:
        `Page ${input.page}: ${total} deals worth $${totalValue.toLocaleString()}. ` +
        `Stage mix favors ${topStage.key} (${topStage.count}/${total}). ` +
        `Top owner ${topOwner.key} carries ${topOwner.count} deals.`,
      metadata: {
        page: input.page,
        total_value: totalValue,
        by_stage: byStage,
        owner_counts: ownerCounts
      }
    }
  }
}

function countBy(records: ReadonlyArray<Record<string, unknown>>, field: string) {
  const out: Record<string, number> = {}
  for (const r of records) {
    const v = r[field]
    if (v === null || v === undefined) continue
    const key = String(v)
    out[key] = (out[key] ?? 0) + 1
  }
  return out
}

function topEntry(counts: Record<string, number>) {
  const [key, count] = Object.entries(counts).sort(([, a], [, b]) => b - a)[0] ?? ['—', 0]
  return { key, count }
}
export const dealNarrativeStrategy = {
  name: 'deal-narrative',
  description:
    'One-paragraph prose summary of a deal page: pipeline mix, total dollar value, ' +
    'top owners. Pick this when preparing a status report.',
  // Only run when the page looks like deals.
  appliesTo(input) {
    return input.model === 'deal' && input.records.length > 0
  },
  generate(input) {
    const total = input.records.length
    const totalValue = input.records.reduce(
      (acc, r) => acc + (typeof r.amount === 'number' ? r.amount : 0),
      0
    )
    const byStage = countBy(input.records, 'stage')
    const topStage = topEntry(byStage)
    const ownerCounts = countBy(input.records, 'owner_id')
    const topOwner = topEntry(ownerCounts)
    return {
      finding:
        `Page ${input.page}: ${total} deals worth $${totalValue.toLocaleString()}. ` +
        `Stage mix favors ${topStage.key} (${topStage.count}/${total}). ` +
        `Top owner ${topOwner.key} carries ${topOwner.count} deals.`,
      metadata: {
        page: input.page,
        total_value: totalValue,
        by_stage: byStage,
        owner_counts: ownerCounts
      }
    }
  }
}
function countBy(records, field) {
  const out = {}
  for (const r of records) {
    const v = r[field]
    if (v === null || v === undefined) continue
    const key = String(v)
    out[key] = (out[key] ?? 0) + 1
  }
  return out
}
function topEntry(counts) {
  const [key, count] = Object.entries(counts).sort(([, a], [, b]) => b - a)[0] ?? ['—', 0]
  return { key, count }
}

Register it on ToolRegistry via an ApiExtension:

examples/summary-strategies-03.ts
import { ToolRegistry, DATA_TOOL_CLASSES, ANALYSIS_TOOL_CLASSES } from '@mcp-rune/mcp-rune/tools'
import { vectorStorage } from '@mcp-rune/mcp-rune/runtime'

import { dealNarrativeStrategy } from './deal-narrative-strategy.js'

const salesExtension: ApiExtension = {
  register(ctx) {
    ctx.registerSummaryStrategy(dealNarrativeStrategy)
  }
}

new ToolRegistry({
  // Strategies run inside analysis_ingest / analysis_summarize, so the
  // analysis tool classes must be registered alongside the data tools…
  toolClasses: { ...DATA_TOOL_CLASSES, ...ANALYSIS_TOOL_CLASSES },
  models: MODEL_CLASSES,
  apiExtensions: { sales: salesExtension },
  // …and the vector-storage gate must pass (initVectorStorage ran with an adapter).
  analysisStorageEnabled: vectorStorage.isVectorStorageEnabled()
})
import { ToolRegistry, DATA_TOOL_CLASSES, ANALYSIS_TOOL_CLASSES } from '@mcp-rune/mcp-rune/tools'
import { vectorStorage } from '@mcp-rune/mcp-rune/runtime'

import { dealNarrativeStrategy } from './deal-narrative-strategy.js'

const salesExtension = {
  register(ctx) {
    ctx.registerSummaryStrategy(dealNarrativeStrategy)
  }
}
new ToolRegistry({
  // Strategies run inside analysis_ingest / analysis_summarize, so the
  // analysis tool classes must be registered alongside the data tools…
  toolClasses: { ...DATA_TOOL_CLASSES, ...ANALYSIS_TOOL_CLASSES },
  models: MODEL_CLASSES,
  apiExtensions: { sales: salesExtension },
  // …and the vector-storage gate must pass (initVectorStorage ran with an adapter).
  analysisStorageEnabled: vectorStorage.isVectorStorageEnabled()
})

Once registered, deal-narrative automatically:

  • Appears in the summary_strategy / summary_strategies enums on analysis_ingest and on strategy / strategies for analysis_summarize.
  • Shows up in the LLM-facing parameter description (one line per strategy, sourced from each strategy’s description).
  • Becomes recallable via analysis_query mode: semantic, category: 'page_summary:deal-narrative' once it has produced a memory.

There is no “register many” form on purpose: one strategy per registerSummaryStrategy() call. Real extensions usually own one or two strategies; bundling more than that is a signal to split the extension.

Category scheme and semantic recall

Every memory produced by a strategy is written with:

  • category: page_summary:<strategy.name> (e.g. page_summary:distribution, page_summary:deal-narrative). Strategies may override this by returning category on their SummaryOutput, but the default is a deliberate, predictable scheme that lets analysis_query filter cleanly.
  • metadata.strategy: the strategy name, redundantly stored on metadata so SQL/JSONB filters can be built against either column.
  • metadata.source (only when written by analysis_summarize): the literal string "analysis_summarize", distinguishing post-hoc re-summarizations from per-page ingest summaries.

Recall examples:

// Just the anomaly findings:
analysis_query({
  analysis_id: "q1-audit",
  mode: "semantic",
  category: "page_summary:anomaly"
})

// Anything related to "missing metadata", across all strategies:
analysis_query({
  analysis_id: "q1-audit",
  mode: "semantic",
  query: "missing metadata"
})

This is the payoff of the per-strategy category: an LLM can query for “anomaly memories about this dataset” without semantic-ranking competing strategies’ summaries against each other.

See also