criogenai

All posts

CriogenAI 0.1.5: MCP tools, a local trace viewer, evals and collections

The CriogenAI team3 min read

  • release
  • mcp
  • tracing
  • evals
  • retrieval

CriogenAI 0.1.5 is on npm and PyPI. It adds four things that real agent apps need sooner or later:

  • tools from MCP servers;
  • a local trace viewer;
  • evals that can fail CI;
  • collections for retrieval.

As always, the logic lives once in the Rust engine. Python, TypeScript and the browser build all get the same behaviour.

pip install -U criogenai
npm install criogenai@latest

Tools from MCP servers#

The Model Context Protocol is how tool servers are shared. CriogenAI can now:

  • start a server as a process (stdio);
  • reach a server at a URL (Streamable HTTP).

Give an agent the whole server, or only the tools you pick:

# A server this app starts (stdio): any MCP server, in any language.
files = await mcp.stdio("npx", ["-y", "@modelcontextprotocol/server-filesystem", "./notes"], prefix="files")

# A server at a URL (Streamable HTTP), with a token in a header.
docs = await mcp.http(
    "https://mcp.example.com/mcp",
    headers={"Authorization": f"Bearer {os.environ['DOCS_MCP_TOKEN']}"},
    prefix="docs",
)

agent = Agent(
    "openai:gpt-5-mini",
    tools=[files, docs.tool("docs_search")],  # a server = all its tools, or pick some
    approve=["files_write_file"],  # MCP tools work with approvals like any tool
)
result = await agent.run("Summarize what ./notes says about the Q3 launch.")
print(result.text)

await files.close()
await docs.close()
// A server this app starts (stdio): any MCP server, in any language.
const files = await mcp.stdio("npx", ["-y", "@modelcontextprotocol/server-filesystem", "./notes"], { prefix: "files" });

// A server at a URL (Streamable HTTP), with a token in a header.
const docs = await mcp.http("https://mcp.example.com/mcp", {
  headers: { Authorization: `Bearer ${process.env.DOCS_MCP_TOKEN}` },
  prefix: "docs",
});

const agent = new Agent({
  model: openai("gpt-5-mini"),
  tools: [files, docs.tool("docs_search")], // a server = all its tools, or pick some
  approve: ["files_write_file"], // MCP tools work with approvals like any tool
});
const result = await agent.run("Summarize what ./notes says about the Q3 launch.");
console.log(result.text);

await files.close();
await docs.close();
use criogenai_agent::{Agent, mcp::McpServer};
use criogenai_providers::{Endpoint, ProviderModel};

// A server this app starts (stdio): any MCP server, in any language.
let files = McpServer::stdio(
    "npx",
    ["-y", "@modelcontextprotocol/server-filesystem", "./notes"],
)
.prefix("files")
.connect()
.await?;

// A server at a URL (Streamable HTTP), with a token in a header.
let token = std::env::var("DOCS_MCP_TOKEN")?;
let docs = McpServer::http("https://mcp.example.com/mcp")
    .header("Authorization", format!("Bearer {token}"))
    .prefix("docs")
    .only(["search"])
    .connect()
    .await?;

let model = ProviderModel::new(
    Endpoint::openai(std::env::var("OPENAI_API_KEY")?),
    "gpt-5-mini",
)?;
let agent = Agent::builder(model)
    .tools(files.tools())
    .tools(docs.tools())
    .build()?;
let result = agent
    .run("Summarize what ./notes says about the Q3 launch.")
    .await?;
println!("{}", result.text().unwrap_or_default());

files.close().await;

MCP tools are ordinary tools. Approvals, timeouts, hooks, streaming and the audit trail all apply to them.

The client only uses tools. A server cannot sample your model or read your files through CriogenAI. Every message is bounded in size and time, and tokens in headers never show up in an error or a log.

CriogenAI speaks MCP 2026-07-28 and falls back to the older handshake, so current and older servers both work. Details are in the MCP guide.

See every run, locally#

Langfuse and any OpenTelemetry backend still work. For day-to-day development there is now a viewer that runs on your own machine:

python -m criogenai.trace     # or: npx criogenai trace
# In a terminal: python -m criogenai.trace   (then open http://127.0.0.1:4318)
from criogenai import Agent, Telemetry

traces = Telemetry.local()  # capture_content=True to see prompts and outputs too
agent = Agent("openai:gpt-5-mini", telemetry=traces)
await agent.run("Where is order A-1001?")
await traces.flush()
// In a terminal: npx criogenai trace   (then open http://127.0.0.1:4318)
const traces = Telemetry.local(); // { captureContent: true } to see prompts and outputs too
const agent = new Agent({ model: "openai:gpt-5-mini", telemetry: traces });
await agent.run("Where is order A-1001?");
await traces.flush();
// In a terminal: criogenai-trace   (then open http://127.0.0.1:4318)
use criogenai_telemetry::{OtlpConfig, OtlpExporter};

let traces = OtlpExporter::start(OtlpConfig::local())?; // .capture_content(true) for prompts
agent
    .run_with(
        "Where is order A-1001?",
        agent.run_options().observe(traces.observer()),
    )
    .await?;
traces.flush().await;

Each run shows as a waterfall:

  • model calls, tool calls and steps;
  • durations and tokens;
  • cost, from the prices you set;
  • warnings and failures.

The viewer listens on 127.0.0.1 only, keeps runs in SQLite, and shows every value as text. See Tracing.

Evals that can fail CI#

An eval runs your agent over a dataset and scores each answer. It then compares the result with your last good run, and fails the build if quality drops.

from criogenai import evals

data = evals.Dataset.load("cases.jsonl")  # {"input": "...", "expected": "..."} per line
result = await evals.evaluate(
    agent,
    data,
    [
        evals.contains(),  # the answer mentions what was expected
        evals.tool_called("lookup_order"),  # and the agent looked it up
        evals.max_latency(5_000),
        evals.judge("openai:gpt-5-mini", "Polite, accurate, and no promises we cannot keep."),
    ],
    concurrency=8,
)
print(result.report())
result.save("eval.json")

# In CI: fail when quality drops against the last good run.
result.check(min_pass_rate=0.9, baseline=evals.EvalResult.load("baseline.json"))
const data = await evals.Dataset.load("cases.jsonl"); // {"input": "...", "expected": "..."} per line
const result = await evals.evaluate(
  agent,
  data,
  [
    evals.contains(), // the answer mentions what was expected
    evals.toolCalled("lookup_order"), // and the agent looked it up
    evals.maxLatency(5_000),
    evals.judge("openai:gpt-5-mini", "Polite, accurate, and no promises we cannot keep."),
  ],
  { concurrency: 8 },
);
console.log(result.report());
await result.save("eval.json");

// In CI: fail when quality drops against the last good run.
result.check({ minPassRate: 0.9, baseline: await evals.EvalResult.load("baseline.json") });
use std::time::Duration;

use criogenai_agent::evals::{self, Check, Dataset, EvalOptions, EvalResult};

let data = Dataset::load("cases.jsonl")?; // {"input": "...", "expected": "..."} per line
let result = evals::evaluate(
    agent,
    &data,
    vec![
        evals::contains(),                     // the answer mentions what was expected
        evals::tool_called("lookup_order", 1), // and the agent looked it up
        evals::max_latency(Duration::from_secs(5)),
    ],
    EvalOptions::default().concurrency(8),
)
.await?;
println!("{}", result.report());
result.save("eval.json", true)?;

// In CI: fail when quality drops against the last good run.
result.check(
    &Check::new()
        .min_pass_rate(0.9)
        .baseline(EvalResult::load("baseline.json")?),
)?;

There are built-in scorers for:

  • exact and fuzzy matches;
  • JSON shape;
  • which tools were called;
  • latency, tokens and cost;
  • grading by a judge model against a rubric.

You can also write your own scorer. All three languages write the same result file, so a baseline recorded in Python can be compared in TypeScript. See Evals.

Collections: search documents by meaning#

A collection stores documents with their embeddings and finds the ones closest to a question. It can also hand an agent a search tool:

from criogenai import Agent, Collection, Embedder

handbook = await Collection.open("handbook", embedder=Embedder.openai(), storage=storage)
await handbook.add(
    [
        {"id": "leave", "text": "Annual leave is 25 days a year.", "metadata": {"section": "hr"}},
        {"id": "expenses", "text": "Expenses are paid within 30 days.", "metadata": {"section": "finance"}},
    ]
)

hits = await handbook.search("How many vacation days?", k=3)
hr_only = await handbook.search("deadlines", k=3, where={"section": "hr"})

agent = Agent(
    "openai:gpt-5-mini",
    instructions="Answer from the handbook; say which section you used.",
    tools=[handbook.as_tool("search_handbook", "Search the staff handbook.")],
)
print((await agent.run("How much leave do I get?")).text)
const handbook = await Collection.open("handbook", { embedder: Embedder.openai(), storage });
await handbook.add([
  { id: "leave", text: "Annual leave is 25 days a year.", metadata: { section: "hr" } },
  { id: "expenses", text: "Expenses are paid within 30 days.", metadata: { section: "finance" } },
]);

const hits = await handbook.search("How many vacation days?", { k: 3 });
const hrOnly = await handbook.search("deadlines", { k: 3, where: { section: "hr" } });

const agent = new Agent({
  model: openai("gpt-5-mini"),
  instructions: "Answer from the handbook; say which section you used.",
  tools: [handbook.asTool("search_handbook", "Search the staff handbook.")],
});
console.log((await agent.run("How much leave do I get?")).text);
use std::sync::Arc;

use criogenai_agent::Agent;
use criogenai_agent::retrieval::{Collection, Document};
use criogenai_providers::{Endpoint, ProviderEmbedder, ProviderModel};
use criogenai_storage::Storage;

let key = std::env::var("OPENAI_API_KEY")?;
let storage = Storage::connect("sqlite://handbook.db").await?;
let embedder = Arc::new(ProviderEmbedder::new(
    Endpoint::openai(key.clone()),
    "text-embedding-3-small",
)?);
let handbook = Collection::open(storage.memory().clone(), "handbook", embedder).await?;
handbook
    .add(vec![
        Document::new("leave", "Annual leave is 25 days a year.").meta("section", "hr"),
        Document::new("expenses", "Expenses are paid within 30 days.")
            .meta("section", "finance"),
    ])
    .await?;

let hits = handbook.search("How many vacation days?", 3).await?;
println!("{}", hits[0].text);

let model = ProviderModel::new(Endpoint::openai(key), "gpt-5-mini")?;
let agent = Agent::builder(model)
    .tool(handbook.as_tool("search_handbook", "Search the staff handbook."))
    .build()?;
agent.run("How much leave do I get?").await?;

Documents live in your CriogenAI storage, so they are encrypted when your database is. The default index is exact: 17 ms a query at 100,000 documents on our test machine (see the benchmarks). An HNSW index is available as an option for large collections.

We did not use sqlite-vec or pgvector, because both would store the vectors unencrypted. See Collections and retrieval.

One caveat for this release#

GitHub Actions cannot build for us until the middle of November, so 0.1.5 was built on Windows:

  • npm: the Windows engine is 0.1.5. Linux and macOS keep the 0.1.3 engine for now. There, the new features raise a clear unsupported error.
  • PyPI: there is a 0.1.5 wheel for Windows. On Linux and macOS, pip installs 0.1.3.
  • Browser: the build for the browser (criogenai/web) has everything.

The other platforms will catch up as soon as the release workflow can run again.

What is next#

Next up are a retrieval kit built on collections, prompt templates, conversation memory, subgraphs, a graph visualizer and background runs. Status: built and planned always shows what exists today and what does not yet.