NEWPromptev MCP Server — One URL. Every tool. Your team’s knowledge. Connect Claude Code, Cursor, or any MCP client.

Context Engine — Full Guide APACHE 2.0

Everything you need to run the engine end-to-end — from install to hybrid + graph search, redaction, tools, and serving it on your own stack. Ships as a Python and a TypeScript client over the same Postgres. BYO Postgres, embeddings, and LLM.

Overview

The Context Engine is the ingestion + retrieval + governance layer that powers Promptev, available as standalone Python and TypeScript packages. It turns your documents into searchable knowledge and serves it behind one ContextEngine object. The samples below are Python; the TypeScript client exposes the same API over the same Postgres schema (TS docs & source →).

You bring

Your Postgres, your embedding provider, and your LLM keys (BYOK). The package never calls a hosted service you didn't configure.

It provides

Hybrid (FTS + trigram + vector) + graph retrieval, ACL, span-level PII redaction, governed tools (HTTP/DB/MCP), and usage accounting.

The 30-second version: install → configure → migrate → ingest() → search(). Everything below expands each step and then covers serving, tools, and integrations.

1 · Install

Requires Postgres with the vector, pg_trgm, and unaccent extensions (auto-created by context-engine migrate if the DB role can CREATE EXTENSION), plus Python 3.12+ or Node 22+ for the client. Both clients share one schema — a corpus ingested by either is searchable by the other.

Python (pip)
pip install promptev-context-engine
Python (uv)
uv add promptev-context-engine
TypeScript (npm)
npm install @promptev/context-engine pg

The core engine is framework-agnostic. Add only the extras / peers you need:

[fastapi] [flask] [django] — the matching HTTP router adapter

[graph] — entity/community graph (Neo4j)  ·  [vision] — scanned pages/images, transcribed by whichever LLM you configure

[ocr] — Tesseract OCR fallback  ·  [mcp] — MCP tool surface  ·  [compute] — dataframe compute

[pdf] — structure-preserving PDF text (tables stay tables)  ·  [presidio] — Presidio entities as redaction detectors

[gemini] — only if your LLM, vision model or embedder is Gemini. Vision itself is provider-agnostic: Claude, GPT-4o, Gemini and Bedrock all work.

hono / express / fastify — the matching HTTP router adapter

neo4j-driver + graphology — entity/community graph  ·  @napi-rs/canvas — vision page rasterization

tesseract.js + sharp — OCR fallback  ·  @modelcontextprotocol/sdk — MCP tool surface  ·  isolated-vm — compute sandbox

pdfjs-dist — structure-preserving PDF text  ·  @google/genai — only if your LLM, vision model or embedder is Gemini

Shell
pip install "promptev-context-engine[fastapi,graph]"
Shell
npm install hono            # createHonoRouter
npm install neo4j-driver graphology   # graph mode

Teach your coding agent the rules

Most integration code is now written with an agent's help, and the costliest mistake in this library is one an agent makes readily: principals=None means trusted caller, ACL filtering disabled — so reading it as “nobody is logged in” returns every document you have.

Python
# WRONG -- returns the ENTIRE corpus to an anonymous caller
principals = user.groups if user else None

# RIGHT -- unauthenticated is an empty list, not None
principals = user.groups if user else []
TypeScript
// WRONG -- returns the ENTIRE corpus to an anonymous caller
const principals = user ? user.groups : null;

// RIGHT -- unauthenticated is an empty list, not null (or TRUSTED to skip ACL)
const principals = user ? user.groups : [];

The package ships a skill stating that and the other invariants worth not guessing — principals are injection-only on remote surfaces, auth/principals are required on every router factory, acl=None unrestricts on update, pgvector 0.8 is a hard floor, and code execution is off by default.

Shell
context-engine install-skill            # -> ./.claude/skills/context-engine/
context-engine install-skill --global   # -> ~/.claude/skills/
context-engine install-skill --print    # stdout, for agents reading other formats
Shell (npx)
npx context-engine install-skill            # -> ./.claude/skills/context-engine/
npx context-engine install-skill --global   # -> ~/.claude/skills/
npx context-engine install-skill --print    # stdout, for agents reading other formats

Explicit, never an install hook. The file becomes instructions inside your own agent, so installing it is your decision rather than something pip does behind you. Re-running after an upgrade is idempotent; a skill you have edited is never overwritten without --force.

2 · Configure

ContextEngineConfig is the single settings object every module reads from. Build it directly, or from CE_-prefixed environment variables (nested fields use __).

Python
from context_engine import ContextEngine, ContextEngineConfig, EmbeddingConfig, LLMConfig

config = ContextEngineConfig(
    database_url="postgresql://user:pass@localhost:5432/mydb",
    embedding=EmbeddingConfig(provider="openai", model="text-embedding-3-small", api_key="sk-..."),
    # llm is optional — only for structured extraction, compute, and the graph
    llm=LLMConfig(provider="openai", model="gpt-4o-mini", api_key="sk-..."),
)
engine = ContextEngine(config)
TypeScript
import { ContextEngine, ContextEngineConfig } from "@promptev/context-engine";

const config = new ContextEngineConfig({
  databaseUrl: "postgresql://user:pass@localhost:5432/mydb",
  embedding: { provider: "openai", model: "text-embedding-3-small", apiKey: "sk-..." },
  llm: { provider: "openai", model: "gpt-4o-mini", apiKey: "sk-..." },
});
const engine = new ContextEngine(config);

Providers (BYOK)

Embeddings: openai · azure_openai · gemini · voyage · cohere · custom (any OpenAI-compatible endpoint via base_url). LLMs add anthropic · bedrock. Anthropic has no embeddings API — use another provider for EmbeddingConfig.

Env-var equivalent:

.env
CE_DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
CE_EMBEDDING__PROVIDER=openai
CE_EMBEDDING__MODEL=text-embedding-3-small
CE_EMBEDDING__API_KEY=sk-...

3 · Provision the database

Create the tables once with the CLI. The migration history lives inside the package, so there's nothing to copy into your repo.

Shell
context-engine migrate --database-url postgresql://user:pass@localhost:5432/mydb --dim 1536

# add --graph to also create entity / relationship / community tables
context-engine migrate --database-url postgresql://... --dim 1536 --graph
Shell (npx)
npx context-engine migrate --database-url postgresql://user:pass@localhost:5432/mydb --dim 1536

# add --graph to also create entity / relationship / community tables
npx context-engine migrate --database-url postgresql://... --dim 1536 --graph

--dim must match your embedding model's vector width (1536 for text-embedding-3-small) and is locked in on first migrate.

4 · Ingest

Feed the engine raw text or files. Files auto-detect type: PDF, DOCX, PPTX, XLSX, CSV, HTML, EML, images, and plain text. Scanned pages and images are transcribed to structured Markdown when a vision LLM is configured (or OCR with the [ocr] extra).

Python
# Plain text
report = await engine.ingest(
    text="Annual leave accrues at two days per month for every band-three employee.",
    name="leave-policy",
    source_id="hr-handbook",       # a namespace to scope search + stats
    acl=["group:hr"],              # None = visible to any trusted caller
)

# A file
with open("policy.pdf", "rb") as f:
    report = await engine.ingest(file=f, name="policy.pdf", source_id="hr-handbook")

print(report.totals)               # totals -> files=1, failed=0, units=3
print(report.documents[0].status)  # completed | failed | batch_pending
TypeScript
import { readFileSync } from "node:fs";

// Plain text
let report = await engine.ingest({
  text: "Annual leave accrues at two days per month for every band-three employee.",
  name: "leave-policy",
  sourceId: "hr-handbook",        // a namespace to scope search + stats
  acl: ["group:hr"],              // TRUSTED = visible to any trusted caller
});

// A file
report = await engine.ingest({ file: readFileSync("policy.pdf"), name: "policy.pdf", sourceId: "hr-handbook" });

console.log(report.totals);                // totals -> { files: 1, failed: 0, units: 3 }
console.log(report.documents[0].status);   // completed | failed | batch_pending

Key options

mode"hybrid" (default) or "graph" (needs the [graph] extra).

extract_structured=True — run an LLM pass to persist document_type / structured_data (needs llm).

batch=True — submit embeddings as an OpenAI batch job; advance later with await engine.resume_batches().

acl — principal strings that may see the document; source_id — a namespace for scoping.

Vision extraction ceilings

Two guardrails on vision extraction, configurable via config.extraction in both clients (defaults shown). max_render_px caps the long edge of rendered PDF pages and sent images — oversized scans are downscaled instead of upscaled to megapixels the provider discards, while normal pages keep their full 150 dpi. vision_max_output_tokens caps one transcription batch, stopping model repetition loops; raise it if very dense pages come back truncated.

Python
from context_engine import ContextEngineConfig, ExtractionConfig

config = ContextEngineConfig(
    # ...
    extraction=ExtractionConfig(
        max_render_px=2000,               # long-edge ceiling for rendered pages & sent images
        vision_max_output_tokens=16_000,  # per-batch transcription ceiling
    ),
)
TypeScript
const config = new ContextEngineConfig({
  // ...
  extraction: {
    maxRenderPx: 2000,            // long-edge ceiling for rendered pages & sent images
    visionMaxOutputTokens: 16000, // per-batch transcription ceiling
  },
});

Env-var equivalent (Python): CE_EXTRACTION__MAX_RENDER_PX · CE_EXTRACTION__VISION_MAX_OUTPUT_TOKENS

ingest() never raises on a single bad document — check report.documents[i].status and .error.

6 · Documents

Every read below takes the same principals contract as search, and every one of them is ACL-filtered in SQL. A document you may not see raises KeyError — the identical error you get for a document that does not exist, so a caller can never probe for the existence of restricted data.

Read one document

Python
doc = await engine.get_document(document_id, principals=["group:hr"])
# {"id", "name", "text", "acl", "status", "chunks", "structured_data", ...}

text = await engine.get_document_text(document_id, principals=["group:hr"])
TypeScript
const doc = await engine.getDocument(documentId, { principals: ["group:hr"] });
// { id, name, text, acl, status, chunks, structuredData, ... }

const text = await engine.getDocumentText(documentId, { principals: ["group:hr"] });

Both apply any apply_at="output" redaction policy before returning. For a corpus ingested without an ingest-phase policy, this is the only thing masking those fields.

List with pagination

Python
page = await engine.list_documents(source_id="hr-handbook", principals=["group:hr"], limit=50)

for summary in page["documents"]:
    print(summary["id"], summary["name"], summary["status"])

if page["has_more"]:
    nxt = await engine.list_documents(source_id="hr-handbook",
                                      principals=["group:hr"],
                                      cursor=page["next_cursor"], limit=50)
TypeScript
const page = await engine.listDocuments({
  sourceId: "hr-handbook", principals: ["group:hr"], limit: 50,
});

for (const summary of page.documents) {
  console.log(summary.id, summary.name, summary.status);
}

if (page.hasMore) {
  const next = await engine.listDocuments({
    sourceId: "hr-handbook", principals: ["group:hr"],
    cursor: page.nextCursor, limit: 50,
  });
}

Keyset pagination, not OFFSET — pages stay stable while documents are being ingested. limit is clamped to 200.

Update attributes without re-ingesting

Python
changed = await engine.update_document(
    document_id,
    acl=["group:legal"],           # replaced; acl=None UNRESTRICTS
    meta_data={"reviewed": True},  # merged, not replaced
    principals=["group:hr"],
)
# -> ["acl", "meta_data"]  (only what actually changed)
TypeScript
const changed = await engine.updateDocument(documentId, {
  acl: ["group:legal"],          // replaced; acl: null UNRESTRICTS
  metaData: { reviewed: true },  // merged, not replaced
  principals: ["group:hr"],
});
// -> ["acl", "meta_data"]  (only what actually changed)

Omitted is not the same as null

Arguments you do not pass are left alone. acl=None means unrestrict this document — it can never mean "leave it". That distinction is why the parameters default to a sentinel rather than to None: otherwise an unrelated rename would silently wipe an ACL. Changing acl also re-pushes it onto every chunk, so retrieval stops enforcing the old rules immediately.

Delete, and corpus counters

Python
await engine.delete_document(document_id, principals=["group:hr"])  # chunks first, then the row

await engine.stats()                        # whole corpus
await engine.stats("hr-handbook")           # one source
# {"documents": 412, "chunks": 9310, "by_status": {"completed": 410}, "embedding": {...}}
TypeScript
await engine.deleteDocument(documentId, { principals: ["group:hr"] }); // chunks first, then the row

await engine.stats();              // whole corpus
await engine.stats("hr-handbook"); // one source
// { documents: 412, chunks: 9310, ... }

Re-ingesting the same document

Ingest is idempotent. A document is identified by external_id first, then name, then a content hash — all scoped to source_id. Re-sending unchanged content returns status="skipped", costs zero units and does not re-embed.

But a skip still applies your declared attributes. acl, name, description and meta_data are not part of the content hash, so re-ingesting identical text with a tightened ACL updates the document and its chunks. Without that, a tightening you believed had applied would silently do nothing.

7 · Structured data & compute

Optional, and both need ContextEngineConfig.llm. Extraction pulls typed fields out of a document at ingest time; query_structured answers questions against them without re-reading the text.

Extract at ingest

Python
report = await engine.ingest(
    file=open("invoice.pdf", "rb"),
    name="INV-1042",
    source_id="billing",
    extract_structured=True,
    field_hints=[{"name": "tax_amount", "type": "number"}],   # optional nudges
)
TypeScript
import { readFileSync } from "node:fs";

const report = await engine.ingest({
  file: readFileSync("invoice.pdf"),
  name: "INV-1042",
  sourceId: "billing",
  extractStructured: true,
  fieldHints: [{ name: "tax_amount", type: "number" }],   // optional nudges
});

One LLM call returns a document_type and a field dict, stored on the document row. Field names are normalised into a registry with embeddings, which is what lets a question say "VAT" and match a field stored as tax_amount.

Query it

Python
answer = await engine.query_structured(
    "what's the VAT on this invoice?",
    source_ids=["billing"],
    principals=["group:finance"],
    doc_type="invoice",
)
# {"resolved_fields": {"vat": "tax_amount"}, "documents": [...], "count": 1}
TypeScript
const answer = await engine.queryStructured("what's the VAT on this invoice?", {
  sourceIds: ["billing"],
  principals: ["group:finance"],
  docType: "invoice",
});
// { resolvedFields: { vat: "tax_amount" }, documents: [...], count: 1 }

If nothing resolves above the similarity floor you get zero documents, not the whole corpus. An off-topic question should return nothing rather than a confident invoice.

compute() — LLM-authored Python over spreadsheets

Off by default, and it should usually stay off

This executes code an LLM wrote, from a prompt that includes document content. The in-process sandbox raises the cost of an escape; it is not a security boundary, and in-process sandboxing of Python cannot be made airtight. Only set enable_code_execution=True if the process calling it is itself isolated — a container, a locked-down subprocess, gVisor, WASM.

Python
config.enable_code_execution = True   # deliberate opt-in

out = await engine.compute(
    "total revenue by region for Q3",
    source_ids=["finance"],
    principals=["group:finance"],
)
# {"success": True, "result": {...}, "code": "...", "documents_used": [...]}
TypeScript
config.enableCodeExecution = true;   // deliberate opt-in

const out = await engine.compute("total revenue by region for Q3", {
  sourceIds: ["finance"],
  principals: ["group:finance"],
});
// { success: true, result: {...}, code: "...", documentsUsed: [...] }

Only CSV/TSV/XLSX documents in scope are loaded, capped at MAX_COMPUTE_DOCUMENTS. Redaction is applied to the text before it becomes a dataframe, so generated code computes over masked values.

8 · Redaction

Mask PII (or anything you can describe with a regex or detector) as content is ingested, retrieved, or returned. Redaction composes with ACL — ACL decides who sees a document; redaction decides what's masked inside it. It's a pure transform: no model, no network.

Python
from context_engine import ContextEngineConfig, RedactionPolicy, RedactionRule

config = ContextEngineConfig(
    database_url="postgresql://...",
    embedding=...,
    redaction=RedactionPolicy(rules=[
        RedactionRule(name="emails", detector="email"),            # -> [EMAILS]
        RedactionRule(name="order_id", pattern=r"\bORD-\d{6}\b"),  # regex rule
    ]),
)
TypeScript
import { ContextEngineConfig, RedactionPolicy, RedactionRule } from "@promptev/context-engine";

const config = new ContextEngineConfig({
  databaseUrl: "postgresql://...",
  embedding: { provider: "openai", model: "text-embedding-3-small", apiKey: "sk-..." },
  redaction: new RedactionPolicy({ rules: [
    new RedactionRule({ name: "emails", detector: "email" }),            // -> [EMAILS]
    new RedactionRule({ name: "order_id", pattern: "\\bORD-\\d{6}\\b" }), // regex rule
  ]}),
});

Built-in detectors: email · phone · ssn · credit_card · iban · api_key.

Actions: mask · hash (keyed, joinable pseudonym — needs secret_key) · remove.

apply_at: output (default — masks on every read, can be principal-conditional via unless) · ingest (scrub before storage) · both.

9 · Tools

Let an agent call http / db / mcp (external server) and function (a Python callable) tools through one governed path — ACL-checked, config AES-256-GCM-encrypted at rest, every call audited.

Python — HTTP tool
from context_engine import ToolConfig

# Register an HTTP tool — the LLM fills `city` at call time
await engine.register_tool(ToolConfig(
    name="get_weather",
    kind="http",
    description="Fetch the current weather for a city",
    config={
        "method": "GET",
        "url": "https://api.example.com/weather",
        "headers": {"Authorization": "Bearer YOUR_TOKEN"},
        "llmQueryParameters": {
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
))  # -> registered as call_name "http_get_weather"

# Execute it (args the LLM produced)
out = await engine.execute_tool("http_get_weather", {"city": "Lahore"})
# -> {"result": {"status_code": 200, "data": {...}}, "usage": {"kind": "tool", "units": 1}}
TypeScript — HTTP tool
import { ToolConfig } from "@promptev/context-engine";

// Register an HTTP tool — the LLM fills `city` at call time
await engine.registerTool(new ToolConfig({
  name: "get_weather",
  kind: "http",
  description: "Fetch the current weather for a city",
  config: {
    method: "GET",
    url: "https://api.example.com/weather",
    headers: { Authorization: "Bearer YOUR_TOKEN" },
    llmQueryParameters: {
      properties: { city: { type: "string" } },
      required: ["city"],
    },
  },
})); // -> registered as call name "http_get_weather"

// Execute it (args the LLM produced)
const out = await engine.executeTool("http_get_weather", { city: "Lahore" });
// -> { result: {...}, usage: { kind: "tool", units: 1 } }
Python — function tool
# A plain Python function becomes a tool — schema inferred from the signature
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

engine.register_function_tool(add)          # -> call_name "fn_add"
await engine.execute_tool("fn_add", {"a": 2, "b": 3})   # -> {"result": 5, ...}
TypeScript — function tool
// A plain function becomes a tool
function add(a: number, b: number): number {
  return a + b;
}

engine.registerFunctionTool(add);                    // -> call name "fn_add"
await engine.executeTool("fn_add", { a: 2, b: 3 });  // -> { result: 5, ... }

Approval gate — a tool with requires_approval=True returns an approval_required payload instead of running, until resolved:

Python
from context_engine import resolve_approval

# A tool registered with requires_approval=True pauses instead of running
out = await engine.execute_tool("http_get_weather", {"city": "Lahore"})
# -> {"approval_required": {"approval_id": "...", ...}}   (NOT executed yet)

await resolve_approval(engine, approval_id, "approved", approver="boss")
# the next execute_tool(...) runs for real
TypeScript
import { resolveApproval } from "@promptev/context-engine";

// A tool registered with requiresApproval: true pauses instead of running
const out = await engine.executeTool("http_get_weather", { city: "Lahore" });
// -> { approvalRequired: { approvalId: "...", ... } }   (NOT executed yet)

await resolveApproval(engine, approvalId, "approved", "boss");
// the next executeTool(...) runs for real

db tools introspect a schema and fail closed to read-only; mcp tools expand into one canonical tool per discovered remote tool, each with its own approval override.

How the gate decides. requires_approval=True gates unconditionally. Otherwise approval_policy["condition"] is evaluated against the call's arguments — a small DSL of the form "amount > 100000". It never uses eval or exec: a regex splits it into field / operator / value and the comparison runs through operator.gt and friends.

It fails closed. Anything that is not a clean parse-and-compare requires approval — an unparseable condition, a field missing from the arguments, a comparison that raises. A typo in a policy produces more human review, never less.

The engine never blocks or polls. It persists a pending record with the arguments frozen into it and returns immediately. Getting the request in front of a human — Slack, your dashboard, email — is the consuming application's job.

Resolution is atomic and single-use. The transition is one conditional UPDATE ... WHERE id = :id AND status = 'pending', not a read-then-write, so at most one of N concurrent resolvers can win. Re-running the tool matches the approved record on tool name plus frozen arguments and claims it — flipping it to executed — so an approval is consumed exactly once and a replay re-gates through a fresh pending record.

Approvals are ACL-scoped. An approval you could not see via list_approvals returns the same "not found" as one that does not exist. Arguments are pinned by the approval; the tool's config is not, so a credential rotated while an approval was outstanding takes effect on execution.

The request template round-trips; its secrets don't. By default the tool admin routes (GET /tools, GET /tools/{id}) return no config in any form — they serve the same ACL-visible callers that may execute a tool. A mount that fronts an editor UI opts in with create_tools_router(..., config_template=True) (TypeScript: createToolsHandlers(engine, { configTemplate: true })), which adds config_redacted: the stored config with every secret masked to "__redacted__" — so a saved tool prefills its form again. Redaction is default-deny: only fields the kind's schema explicitly round-trips pass through; headers, parameters, password, oauth, unknown keys and unknown kinds are masked, keys kept.

Hang your own metadata on a tool. Every tool row carries meta_data — an engine-opaque JSON object, same as documents. Set it on POST /tools, replace it on PATCH ({} clears it), read it on the admin GET routes. It never rides on the agent-facing views, and it is stored in clear — secrets belong in config, which is encrypted. For relational side-data, make your own table keyed on the tool id; the engine deliberately does not support user-defined columns in its own tables.

Echoing the sentinel back means "keep what's stored". A PATCH that re-sends "__redacted__" restores the stored value path-for-path before re-encrypting, so an ordinary re-save can never corrupt a credential. A sentinel that resolves to nothing — no stored config, a key rotated since, a kind change in the same PATCH — is a 400, never a silent drop, and POST /tools refuses a sentinel-bearing config outright. After a key rotation, re-entering the config in full heals the tool; a row whose blob no longer decrypts lists with config_error: "undecryptable" instead of failing the whole listing.

10 · Serve over HTTP

The document + search surface ships as a ready adapter for FastAPI, Flask, or Django — identical routes, identical behavior. auth and principals are yours to inject; the package implements no auth.

Both arguments are required

Resolving who the caller is has no safe default, so there isn't one — every factory (create_router, create_flask_blueprint, create_django_urlpatterns, create_tools_router, create_mcp_app) requires them. To run a mount open on purpose, say so: principals=lambda: [].

A caller may only file a document under an ACL it already holds — POST /documents and PATCH /documents/{id} answer 403 otherwise, so an untrusted request cannot push content into another group's retrieval scope.

FastAPI
from context_engine import create_router

router = create_router(engine, auth=my_auth, principals=my_principals)
app.include_router(router, prefix="/context")
Flask
from context_engine import create_flask_blueprint

bp = create_flask_blueprint(engine, auth=my_auth, principals=my_principals)
app.register_blueprint(bp, url_prefix="/context")
Django (urls.py)
from django.urls import include, path
from context_engine import create_django_urlpatterns

urlpatterns = [
    path("context/", include(create_django_urlpatterns(engine, auth=my_auth, principals=my_principals))),
]
TypeScript — Hono / Express / Fastify
import { createHonoRouter } from "@promptev/context-engine/hono";

const router = createHonoRouter(engine, { auth: myAuth, principals: myPrincipals });

// Identical routes exist for Express (createExpressRouter, "@promptev/context-engine/express")
// and Fastify (createFastifyPlugin, "@promptev/context-engine/fastify").
// auth and principals are REQUIRED on every router factory.

Routes: POST/GET/DELETE/PATCH /documents, POST /search, GET /stats. Not on any framework? Call engine.ingest() / engine.search() directly from your own views.

11 · MCP Server

Expose the engine to any MCP client (Claude Code, Cursor, …) over streamable HTTP. Requires the [mcp] extra.

Shell
context-engine mcp --database-url postgresql://... --port 8080
Shell (npx)
npx context-engine mcp --database-url postgresql://... --port 8080
Python (mount in your ASGI app)
from context_engine import create_mcp_app

app.mount("/mcp", create_mcp_app(engine, principals=my_principals))
TypeScript
import { createMcpApp } from "@promptev/context-engine";

const app = createMcpApp(engine, { auth: myAuth, principals: myPrincipals });

Registers search_knowledge_base + get_document always, query_structured when an LLM is set, and compute only when code execution is explicitly enabled.

12 · Usage, errors & lifecycle

The engine reports its own work through two callbacks you pass at construction — one for metering, one for errors. It never calls your metrics backend or error tracker itself, so what a unit means and where an exception goes stay entirely yours.

Python
from context_engine import ContextEngine, UsageEvent

def record_usage(event: UsageEvent) -> None:
    # kind: "ingest" | "search" | "tool"
    # detail: {"pages": 42} / {"mode": "graph"}
    # provider_tokens: {"embedding_tokens": n, "llm_input": n, "llm_output": n}
    metrics.increment(f"context_engine.{event.kind}", event.units, tags=event.detail)
    metrics.record(event.provider_tokens)

def alert(exc: Exception, ctx: dict) -> None:
    sentry.capture_exception(exc, extra=ctx)   # ctx says which document/stage

engine = ContextEngine(config, on_usage=record_usage, on_error=alert)
TypeScript
import { ContextEngine, type UsageEvent } from "@promptev/context-engine";

function recordUsage(event: UsageEvent): void {
  // kind: "ingest" | "search" | "tool"
  // detail: { pages: 42 } / { mode: "graph" }
  // providerTokens: { embeddingTokens, llmInput, llmOutput }
  metrics.increment(`context_engine.${event.kind}`, event.units, event.detail);
  metrics.record(event.providerTokens);
}

function alert(exc: unknown, ctx: Record<string, unknown>): void {
  sentry.captureException(exc, { extra: ctx }); // ctx says which document/stage
}

const engine = new ContextEngine(config, { onUsage: recordUsage, onError: alert });

Units are deliberately unit-less counts — a measure of work done, not a price: 1 per PDF page, 1 per PPTX slide, 1 per DOCX page, 1 flat per image, otherwise 1 per MB rounded up (minimum 1), plus 1 per picture that got an LLM-generated description. Search and tool calls emit their own events. Attach whatever meaning you need — quota, chargeback, capacity planning, or nothing at all.

Both hooks are non-fatal by design. If your callback raises, the engine logs a warning and swallows it — an outage in your metrics or alerting stack must never fail an ingest. on_error also receives the unmasked exception even when the same error is redacted on read surfaces like GET /documents/{id}, so you keep full debuggability without leaking document text to a caller.

Closing the engine

Construction is lazy — the connection pool, embedding client and Neo4j driver are built on first use. aclose() tears down only what was actually built.

Python
# one engine for the process lifetime -- the usual case
engine = ContextEngine(config)

# anything that builds engines repeatedly must close them, or it
# accumulates connection pools: a worker per job, a test per case,
# an app reloading its configuration.
async with ContextEngine(config) as engine:
    await engine.ingest(file=f, name="handbook.pdf", source_id="hr")

# equivalently
engine = ContextEngine(config)
try:
    ...
finally:
    await engine.aclose()   # safe to call more than once
TypeScript
// one engine for the process lifetime -- the usual case
const engine = new ContextEngine(config);

// anything that builds engines repeatedly must close them, or it
// accumulates connection pools: a worker per job, a test per case,
// an app reloading its configuration.
await using scoped = new ContextEngine(config);   // Symbol.asyncDispose -> aclose()
await scoped.ingest({ file: f, name: "handbook.pdf", sourceId: "hr" });

// equivalently
const e = new ContextEngine(config);
try {
  // ...
} finally {
  await e.aclose();   // safe to call more than once
}

13 · Verify your setup

Two checks worth running before you trust a deployment — one against your database, one against your retrieval quality.

Managed-Postgres preflight

Neon, Supabase, RDS, Cloud SQL and Azure all ship pgvector, so "does it work" is rarely the question. These are the four that actually bite:

Shell
# point at a SCRATCH database -- it runs the real migrations
CE_COMPAT_DATABASE_URL='postgresql+psycopg2://user:pass@host/db' \
    uv run pytest tests/test_managed_postgres_compat.py -v -s
  • pgvector ≥ 0.8 — below it there is no iterative scan, and ACL-filtered vector search loses recall with no fix available from this package.
  • Can this role CREATE EXTENSION vector, pg_trgm, unaccent? Several providers gate that behind an allow-list.
  • Are those functions on the search_path? The Supabase trap: extensions land in an extensions schema, CREATE EXTENSION succeeds, and then similarity() fails at query time. Fix with ALTER ROLE <role> SET search_path = public, extensions;
  • Migrations + an ACL-filtered HNSW query end to end on the provider's own engine.

The ACL-recall benchmark

Any system that applies an access-control predicate as a WHERE clause over an approximate index is post-filtering: the index walk is ordered by distance, rejected rows are discarded as they are met, and the walk stops when its budget is spent. If the caller's visible slice is small, the budget goes entirely on rows they may not see — and the query returns nothing, successfully, with nothing logged.

Shell
uv sync
uv run python benchmarks/acl_recall/run.py --dataset scifact --out results.csv
permissionsvisiblepost-filter recallemptywith iterative scan
random10%0.9800.0%0.980
clustered10%0.47325.0%0.928
random1%0.17520.8%0.966
clustered1%0.08775.0%0.883

200,000 documents from BEIR nq, real 1536-dim text-embedding-3-small embeddings, exact brute-force oracle over the eligible rows. Same corpus, same index, same queries, same number of visible rows — only the arrangement differs. Every published ACL benchmark assigns permissions at random; real permissions are topically clustered, which roughly doubles the loss. Iterative scan does not fully close it (0.883 vs 0.966), which is why this library routes to an exact scan below an eligible-row threshold rather than only tuning parameters. You may not reproduce the failure at all: with a GIN index on the ACL column the planner often picks an exact bitmap scan instead of the vector index — the table above forces the index. Full method and limitations in benchmarks/acl_recall/README.md.

14 · LangChain & LlamaIndex

The engine is a substrate, not an orchestrator — it slots into LangChain (or LlamaIndex / Haystack) as a retriever. Wrap engine.search() in a BaseRetriever and your existing chains get hybrid + graph retrieval with ACL and redaction, in a few lines.

Python — LangChain retriever
from langchain_core.retrievers import BaseRetriever
from langchain_core.documents import Document

class PromptevRetriever(BaseRetriever):
    engine: ContextEngine

    async def _aget_relevant_documents(self, query, *, run_manager):
        result = await self.engine.search(query, principals=["group:hr"], top_k=8)
        return [
            Document(page_content=hit.chunk_text, metadata=hit.meta)
            for hit in result.hits
        ]

# drop it into any chain
retriever = PromptevRetriever(engine=engine)
docs = await retriever.ainvoke("how much annual leave do I get")
TypeScript
// No shipped TS adapter — call the engine directly, or wrap engine.search()
// in a LangChain.js BaseRetriever yourself.
const result = await engine.search(query, { principals, topK: 8 });
const docs = result.hits.map((h) => ({ pageContent: h.chunkText, metadata: h.meta }));

Why this shape: the engine executes and governs (retrieval, ACL, redaction, audited tools); the framework orchestrates. You keep your chain and gain a governed, self-hosted context layer underneath it.

15 · Production

Cloud-agnostic

Just Postgres (pgvector) + your OpenAI-compatible endpoint. No boto3/GCP/Azure lock-in; graph is an optional Neo4j add-on. Runs anywhere Postgres does.

Observability

Wire the on_usage and on_error hooks for billing + telemetry; every tool call writes an audit row.

Scale-to-zero safe

Ingest is synchronous and idempotent per content_hash; re-ingesting unchanged content is a no-op. Use batch=True for large backfills.

Migrations

Run context-engine migrate on deploy. Alembic history is bundled in the package (context_engine_alembic_version).

Get the package

PyPI →  ·  npm (TypeScript) →  ·  GitHub →

500 free credits · No card required · Cancel anytime