Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions hyperdb-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
immediately after `CREATE TABLE IF NOT EXISTS`. `detach_database`
clears the alias's entry so a re-attach to a different file isn't
served stale.
- **Cross-process catalog write safety via optimistic concurrency.**
`upsert_stub_in` now uses UPDATE-then-conditional-INSERT instead
of DELETE+INSERT in a transaction. Each statement is individually
atomic at the `hyperd` level, so multiple MCP server processes
sharing the same persistent database via the daemon can no longer
produce duplicate `_table_catalog` rows from concurrent ingests.
The UPDATE path also eliminates one round-trip (the pre-read of the
existing row to preserve prose fields — UPDATE preserves them
implicitly by only touching mechanical columns).

### Fixed

Expand Down
20 changes: 17 additions & 3 deletions hyperdb-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,31 @@ Built on the pure-Rust [`hyperdb-api`](../hyperdb-api/) crate for maximum perfor

LLMs are powerful at reasoning but cannot natively crunch millions of rows. This plugin bridges that gap: another MCP tool produces data, the LLM passes it to `hyperdb-mcp`, Hyper ingests it and makes it SQL-queryable, the LLM runs analytical SQL, and results come back as JSON. Optionally export to CSV, Parquet, Apache Iceberg, Arrow IPC, or `.hyper` (opens directly in **Tableau Desktop**).

### Queryable Memory for AI

Unlike flat-text memory systems that store blobs and retrieve by similarity search, HyperDB gives LLMs **structured, queryable long-term memory**. The persistent database survives across sessions — anything the LLM stores there can be JOINed, filtered, aggregated, and reasoned over with full SQL in any future conversation.

This means an LLM can:
- **Accumulate knowledge over time** — store reference tables, project decisions, user preferences, learned facts
- **Cross-reference across sessions** — JOIN today's analysis against historical data from last week
- **Answer complex recall questions** — "Which projects had budget overruns in Q1?" is a SQL query, not a fuzzy text search
- **Build on prior work** — load yesterday's cleaned dataset and extend it without re-processing from scratch
- **Maintain structured context** — store relationship graphs, timelines, or decision logs as proper tables with typed columns

The ephemeral database is scratch space (think: a whiteboard). The persistent database is long-term memory (think: a filing cabinet you can query). Multiple AI clients sharing the same daemon see the same persistent data — so Claude Code, Cursor, and VS Code Copilot can all read from and contribute to the same knowledge base.

---

## Features

- **Zero setup** — `HyperProcess` auto-starts the Hyper server
- **Shared `hyperd` daemon** — one Hyper process per user, shared across all MCP clients (Claude Code, Cursor, VS Code, etc.) for reduced memory overhead and concurrent access to the same persistent databases
- **Queryable long-term memory** — persistent database survives across sessions; LLMs can store, recall, JOIN, and aggregate structured knowledge over time — not just retrieve text blobs, but reason over them with SQL
- **Any data in** — JSON, CSV, Parquet, Arrow IPC, Apache Iceberg; schema inferred or exact
- **SQL at scale** — thousands to billions of rows
- **Data out** — export to CSV, Parquet, Apache Iceberg, Arrow IPC, or `.hyper` (Tableau Desktop-ready)
- **One-shot queries** — `query_file("/tmp/sales.csv", "SELECT ...")` — single call, zero management
- **Persistent workspace** — load multiple tables, JOIN across them, persist across sessions
- **Cross-session continuity** — load multiple tables, JOIN across them, persist across sessions; pick up exactly where you left off
- **Read-only safe mode** — `--read-only` flag for safe deployment
- **Schema resources** — auto-discover table schemas via `resources/list`
- **Guided prompts** — `analyze-table`, `compare-tables`, `data-quality`, `suggest-queries`
Expand Down Expand Up @@ -163,7 +177,7 @@ Any tool that supports the MCP stdio transport can use this server. Point it at

## Operating Modes

Each session has **two databases**: an ephemeral primary (always created fresh per session, deleted on exit) and a persistent attachment (stored at the platform-default location or a path you supply). Unqualified SQL targets the ephemeral primary; the persistent attachment is reachable as the `"persistent"` alias and survives across sessions.
Each session has **two databases**: an ephemeral primary (scratch space — always created fresh per session, deleted on exit) and a persistent database (queryable long-term memory — stored at the platform-default location or a path you supply, survives indefinitely). Unqualified SQL targets the ephemeral primary; the persistent database is reachable as the `"persistent"` alias.

### Hyper engine

Expand All @@ -186,7 +200,7 @@ The shared daemon is the bigger win for users running multiple AI clients (Claud

### Working with both databases

Tool calls default to the ephemeral primary — that's the LLM's scratch space for exploratory work. There are two ways to reach the persistent attachment:
Tool calls default to the ephemeral primary — that's the LLM's scratch space for exploratory work that doesn't need to outlive the session. To store data in long-term memory (the persistent database), there are two ways to reach it:

**1. Per-tool `database` parameter** (preferred for ergonomic LLM workflows):

Expand Down
47 changes: 38 additions & 9 deletions hyperdb-mcp/src/readme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,52 @@ or shell pipelines: it parses files faster, runs SQL natively, and keeps
intermediate state in a workspace database the LLM can re-query without
re-loading.

## Workspace model
## Workspace model — queryable memory

Every session has TWO databases, plus optional user-attached ones:

- **Ephemeral primary** (default destination). Created fresh per
session, deleted on exit. Unqualified SQL routes here. Use for
scratch work and intermediate transformations the user doesn't
need to keep.
- **Persistent attachment** under alias `\"persistent\"`. Survives
across sessions at the platform-default path (or override via
`--persistent-db`). Use when the user wants the data to stick
around. Disabled when the server runs with `--ephemeral-only`.
session, deleted on exit. Unqualified SQL routes here. Use as
scratch space for exploratory work, intermediate transformations,
and one-off analysis the user doesn't need to keep.
- **Persistent database** (alias `\"persistent\"`). Survives across
sessions — this is your **long-term structured memory**. Store
reference tables, accumulated results, user preferences, learned
facts, or any data you want to recall in future conversations.
Unlike flat-text memory, persistent data is **queryable**: you can
JOIN, filter, aggregate, and reason over it with SQL. Disabled
when the server runs with `--ephemeral-only`.
- **User-attached writable databases** via `attach_database` with
`writable: true`. Each lives in its own `.hyper` file under a
user-chosen alias.

Pick a destination in one of two ways:
### Persistent as memory — when and how to use it

Store data in persistent whenever:
- The user says \"remember this\", \"save this\", \"keep this\"
- You produce a useful reference table (lookups, configs, mappings)
- You accumulate results across multiple conversations
- You want to recall context in future sessions

Retrieve from persistent whenever:
- You need context from a prior session
- The user asks \"what do we have?\" or \"show me what's saved\"
- You want to JOIN current scratch work against historical data

```
// Save something for later
load_data({ table: \"project_decisions\", data: \"[...]\", persist: true })

// Recall it next session
query({ sql: \"SELECT * FROM project_decisions\", database: \"persistent\" })

// Cross-reference: join session scratch with persistent memory
query({ sql: \"SELECT s.*, p.decision FROM scratch_analysis s \
JOIN \\\"persistent\\\".\\\"public\\\".\\\"project_decisions\\\" p \
ON s.topic = p.topic\" })
```

### Routing data to a destination

- **`database` parameter** (preferred for tools that build their own
SQL): `query`, `execute`, `load_data`, `load_file`, `load_files`,
Expand Down
43 changes: 35 additions & 8 deletions hyperdb-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
AnnotateAble, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, Implementation,
ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams,
PromptMessage, PromptMessageRole, RawResource, RawResourceTemplate, ReadResourceRequestParams,
ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
UnsubscribeRequestParams,
InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
ListResourcesResult, PaginatedRequestParams, PromptMessage, PromptMessageRole, RawResource,
RawResourceTemplate, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
ServerCapabilities, ServerInfo, SubscribeRequestParams, UnsubscribeRequestParams,
};
use rmcp::service::RequestContext;
use rmcp::{
Expand Down Expand Up @@ -827,6 +827,10 @@ pub struct HyperMcpServer {
no_daemon: bool,
/// Last time a heartbeat was sent to the daemon (debounced to avoid per-call TCP overhead).
last_heartbeat: std::sync::Mutex<std::time::Instant>,
/// MCP client name from the `initialize` handshake (e.g. "Claude Code",
/// "cursor-mcp-client"). Populated once per session; used for catalog
/// provenance tracking (`created_by` / `last_modified_by`).
client_name: std::sync::Mutex<Option<String>>,
// Under rmcp 1.x the router fields are constructed for downstream
// macro-generated dispatch but not read through a direct field access
// that the compiler can see. Keep them; the `#[tool_router]` /
Expand Down Expand Up @@ -899,6 +903,7 @@ impl HyperMcpServer {
read_only,
no_daemon,
last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
client_name: std::sync::Mutex::new(None),
tool_router: Self::tool_router(),
prompt_router: Self::prompt_router(),
}
Expand Down Expand Up @@ -942,6 +947,12 @@ impl HyperMcpServer {
self.subscriptions.notify_list_changed();
}

/// The MCP client name from the `initialize` handshake, or `None` if
/// the handshake hasn't completed yet. Used for catalog provenance.
fn client_name(&self) -> Option<String> {
self.client_name.lock().ok().and_then(|g| g.clone())
}

/// Return a clone of the engine Arc so background tasks (watchers) can
/// share access to the same lazy-initialized engine instance.
#[must_use]
Expand Down Expand Up @@ -1129,10 +1140,6 @@ impl HyperMcpServer {
/// their own per-DB catalog. Read-only attachments are rejected
/// upstream by `resolve_db(require_writable=true)` so this helper
/// never sees them.
#[expect(
clippy::unused_self,
reason = "&self required for method-call dispatch; body uses only engine + params"
)]
fn after_ingest_catalog_update(
&self,
engine: &Engine,
Expand All @@ -1150,6 +1157,7 @@ impl HyperMcpServer {
row_count,
true,
target_db,
self.client_name().as_deref(),
) {
tracing::warn!(
table = %table_name,
Expand Down Expand Up @@ -3839,6 +3847,25 @@ Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-
info
}

async fn initialize(
&self,
request: InitializeRequestParams,
context: RequestContext<RoleServer>,
) -> Result<InitializeResult, rmcp::ErrorData> {
let name = &request.client_info.name;
let version = &request.client_info.version;
let label = if version.is_empty() {
name.clone()
} else {
format!("{name} {version}")
};
if let Ok(mut guard) = self.client_name.lock() {
*guard = Some(label);
}
context.peer.set_peer_info(request);
Ok(self.get_info())
}

/// Handle a `resources/subscribe` request by recording the calling
/// peer in the registry under the requested URI.
///
Expand Down
Loading
Loading