| title | Adding a Source Adapter |
|---|---|
| created | 2026-07-07 |
| updated | 2026-08-24 |
A source adapter turns a resolved source into SourceDocument values without bypassing Axon's shared source pipeline. This guide is the practical companion to docs/pipeline-unification/sources/new-source-contract.md.
See also crates/axon-adapters/src/CLAUDE.md, docs/pipeline-unification/sources/adapter-scopes.md, docs/pipeline-unification/sources/metadata-payload.md, and docs/pipeline-unification/sources/source-graph.md.
A provider name is not, by itself, a reason to add a new source family. Prefer an existing acquisition boundary when it already matches the source shape.
| Input shape | Preferred Axon boundary |
|---|---|
| web page/site | web / feed |
| package registry | registry |
| local or exported file | local / upload |
| CLI-generated data | cli_tool |
| MCP server/tool data | mcp_tool |
| genuinely new acquisition/lifecycle model | new SourceAdapter |
Asana and Linear accessed through MCP should therefore start as MCP ingestion profiles, not AsanaSourceAdapter / LinearSourceAdapter. Promote a provider later only when Axon needs provider-native cursors, webhooks, attachment acquisition, delete/tombstone semantics, or authentication that no longer belongs behind MCP.
SourceRequest -> SourceResolver -> SourceRouter -> SourceAdapter
-> SourceLedger -> SourceDocument -> SourceParseFacts / GraphCandidate
-> DocumentPreparer -> EmbeddingProvider -> VectorStore -> DocumentStatus
Adapters own acquisition and normalization. They do not own ledger persistence, final chunking, embeddings, vector writes, graph persistence, job storage, or transport rendering.
Export useful data to JSON, NDJSON, or Markdown and ingest it through the local/upload path. This proves retrieval quality quickly, but the source identity remains file-backed and should be treated as disposable.
Even when writing a file, emit deterministic logical record identifiers and canonical external URIs. At minimum preserve source_item_key, canonical_uri, title, normalized content, update timestamp, provider metadata, and known relationships.
For data available through MCP, extend the existing mcp_tool family so one authorized MCP invocation can return a structured multi-item snapshot. Axon promotes those records into SourceManifest items and SourceDocument values instead of indexing the entire tool response as one opaque document.
Recommended ownership:
external SaaS
-> upstream MCP server
-> Labby gateway
- upstream discovery
- OAuth/token refresh
- route/loadout/tool policy
- snippet execution
-> axon.mcp-ingest/v1 snapshot
-> Axon mcp_tool adapter
- stable item identity
- manifest + diff
- SourceDocument normalization
-> shared parse/graph/prepare/embed/publish pipeline
Axon authenticates to Labby, not to Asana/Linear directly. Upstream OAuth credentials remain owned by Labby and never enter Axon source options, metadata, artifacts, graph facts, or vector payloads.
Labby's upstream OAuth state is subject-scoped. A homelab/single-user deployment may use a dedicated Axon ingestion service identity whose Labby subject has authorized the needed upstreams. Multi-user deployments must preserve or explicitly delegate the correct Labby subject instead of silently sharing one user's OAuth state.
Promote a provider only when native acquisition semantics materially improve correctness or operations. Preserve the logical item keys and canonical URIs established by the MCP profile so migration is deterministic.
The current mcp_tool code supports metadata-only behavior and an explicitly authorized call path, but call output is effectively one document. General ingestion needs a multi-record contract. A proposed shape is:
{
"schema": "axon.mcp-ingest/v1",
"source": { "provider": "asana", "scope": "project", "external_id": "123" },
"complete_snapshot": true,
"items": [
{
"source_item_key": "asana:task:12091234567890",
"canonical_uri": "https://app.asana.com/0/PROJECT/TASK",
"content_kind": "structured",
"title": "Example task",
"body": "Normalized task content",
"updated_at": "2026-08-24T20:00:00Z",
"metadata": { "provider": "asana", "task_id": "12091234567890" },
"relationships": []
}
],
"cursor": null
}Required invariants:
- item keys and canonical URIs are deterministic and provider-stable;
- unchanged items hash identically and skip re-embedding;
- removals are inferred only from an explicitly complete snapshot or explicit tombstones;
- secrets and authorization material never enter persisted source content;
- raw provider payloads are optional evidence artifacts, not canonical vector payloads;
- the Labby provider accepts arguments/input, not only a server/tool name;
- large sets use pagination/cursors or another bounded mechanism.
SourceAdapter::materialize already runs once before discover / acquire / normalize. Existing registry, Reddit, and feed adapters use it to fetch once into a temporary dump. MCP ingestion should follow that pattern:
materialize: call Labby once -> private temporary axon.mcp-ingest/v1 dump
discover: read dump -> SourceManifest
ledger: diff against prior generation
acquire: select added/modified records from the same dump
normalize: one SourceDocument per selected record
release: discard temporary materialization
This gives us the fast 'dump the Asana data and embed it' prototype without a persistent intermediate file and without hitting Asana twice.
Temporary materialization is sensitive storage, not an ordinary cache. Use an
OS-created unpredictable directory with mode 0700, create the dump
exclusively with mode 0600, reject/fail rather than follow symlinks, and keep
raw paths and contents out of logs. Cleanup must be owned by an RAII/scope guard
that runs after success, error, cancellation, and unwind; release remains the
normal-path lifecycle hook, not the only cleanup mechanism. Tests must verify
private permissions and removal after both provider failure and cancellation.
Moving MCP execution behind Labby should remove Axon-specific caller-command plumbing rather than create a second execution path.
CommandMcpToolCallerplus itsCliToolSource/execute_commandbridge incrates/axon-adapters/src/mcp_tool.rs.command_caller(plan)andmcp_caller_commandparsing incrates/axon-adapters/src/mcp_tool/adapter.rs.AXON_MCP_CALLER_COMMANDandAXON_MCP_CALLER_ALLOWLIST, plus corresponding fields/validation incrates/axon-services/src/source/dispatch/tool_auth.rs.mcp_caller_command,mcp_caller_allowlist, and MCP-specificenv_allowlistroute option keys incrates/axon-route/src/capability.rs.- command-caller tests that only prove
/bin/echoor another local helper is configured/allowlisted. - generated capability/reference fields for those removed options.
SourceKind::McpTool,SourceFamily::McpTool, themcp_tooladapter, canonicalmcp://...identity, andscope=tool/api.axon:executeand an Axon-side exact MCP target allowlist as defense in depth.- authorization audit events, but record Labby identity/route/target rather than a local caller command.
- timeout and response-size limits, applied to the Labby request/result in addition to Labby's limits.
- redaction and artifact capture, with artifacts representing bounded/redacted Labby/provider results rather than local stdout/stderr.
- metadata-only tool-contract indexing, but obtain real tool metadata/schema from Labby rather than manufacturing a placeholder document.
- Replace
McpToolCaller::call(&target)with a provider boundary such asMcpSourceProvider/McpIngestionProviderthat accepts target + arguments and returns a bounded structured result. - Inject the provider into
McpToolSourceAdapterfromcrates/axon-services/src/source/adapter_registry.rs, following the existing upload/memory provider pattern.axon-adaptersstays Labby-agnostic. - Implement
McpToolSourceAdapter::materializeand a temporary dump parser foraxon.mcp-ingest/v1. - Change ingestion-mode
discoverfrom one manifest item per server/tool to one per record. - Change ingestion-mode
acquireto select only added/modified records from the materialized snapshot and never call Labby a second time. - Change ingestion-mode
normalizeto emit oneSourceDocumentper selected record. - Update
crates/axon-adapters/src/mcp_tool/metadata.rs: preserve shared MCP/tool provenance while merging approved provider/profile metadata, record an ingestion action distinctly from metadata-only indexing, and keep provider fields bounded/redacted. - Remove job-id-based
execution_content_hashfor ingestion records; derive hashes from stable normalized record content. - Define the external
axon.mcp-ingest/v1DTO/schema in a transport-neutral contract layer, normallyaxon-api, because Labby snippets produce it and Axon consumes it. - Add Labby endpoint/service-identity configuration in the service/config layer. Secrets belong in environment/credential storage, not source options.
- Add the concrete Labby provider in
axon-services. Do not makeaxon-servicesdepend onaxon-mcp:axon-mcpalready depends onaxon-services, so that would form a dependency cycle. Reuse the existing service HTTP stack or a lower-level reusable MCP client dependency.
When a genuinely new source family is warranted, follow these steps.
Specify SourceKind / SourceFamily, supported URI schemes, source canonical URI, stable source-id behavior, deterministic source_item_key, item canonical URI, and mutable/immutable identity rules before implementing network calls.
crates/axon-adapters/src/spec.rs defines SourceFamily, ParserFamily, and SourceAdapterSpec. Scope tables and matrix entries are split under crates/axon-adapters/src/family_matrix/ (matrix.rs, scopes_content.rs, and scopes_tooling.rs). Add or update the appropriate rows there.
Declare scopes, credentials, parser/metadata families, watch/refresh support, network/local/tool capabilities, degradation modes, and required/optional graph facts honestly. Security capability flags are policy inputs, not decorative documentation.
If a new source or scope enum value is truly required, update the transport-neutral DTOs in axon-api; do not create adapter-local shadow enums.
Resolver/router changes belong in axon-route. Explicit schemes must be deterministic; ambiguous shorthand must fail instead of silently selecting a family. The router selects adapter/scope/options/parser hints but does not perform acquisition.
The live trait includes materialize, discover, acquire, normalize, optional progress/prefetch/archive hooks, and release. Use materialize when acquisition needs one-time prepared state before manifest discovery.
Core sequence:
async fn discover(&self, plan: &SourcePlan) -> Result<SourceManifest>;
async fn acquire(
&self,
plan: &SourcePlan,
diff: &SourceManifestDiff,
) -> Result<SourceAcquisition>;
async fn normalize(
&self,
plan: &SourcePlan,
acquisition: SourceAcquisition,
) -> Result<StageExecutionResult<Vec<SourceDocument>>>;Do not embed, write Qdrant, commit ledger generations, write graph rows, own job storage, or render transport responses from the adapter.
Concrete source adapters are assembled in crates/axon-services/src/source/adapter_registry.rs and validated against the normative family matrix by SourceAdapterRegistry::validate(). Add provider dependencies there when an adapter requires an injected runtime service.
Update adapter-scopes.md, url-normalization.md, metadata-payload.md, and source-graph.md before or with the code. Prefer existing parsers and chunk profiles unless new structure materially improves retrieval/graph semantics.
Required adapter fixtures live under crates/axon-adapters/fixtures/<adapter>/ and cover resolution, manifests, source documents, source jobs, auth, degraded behavior, provider failures, and metadata. Add the corresponding parser, graph, and vector-payload fixtures where required.
Prove explicit resolution, ambiguous rejection, added/modified/removed/unchanged diffing, normalization, auth failure, degradation/provider failure, redaction, source-job publication, graph declarations, and payload validity.
After changing schema inputs or adapter capabilities, use cargo xtask generated-contracts refresh, then cargo xtask generated-contracts check. Do not hand-edit generated reference artifacts.
Run focused route/adapter/service/parser/graph/generated-contract checks required by the changed files. Prose-only documentation changes do not require a full Rust build.
A source is online when identity is deterministic, resolver/router behavior is explicit, acquisition/normalization use the shared pipeline, refresh skips unchanged embeddings, removals become durable cleanup work, metadata/graph evidence validate, security fails closed, fixture packs exist, generated capability surfaces match runtime behavior, and user-facing docs describe what is actually supported.