mirror of
https://gitea.ingwaz.work/Ingwaz/openbrain-mcp.git
synced 2026-03-31 14:49:06 +00:00
Fix Issue #12 tests and add OpenBrain repo guidance
This commit is contained in:
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# OpenBrain MCP Usage
|
||||||
|
|
||||||
|
When working in this repository, treat OpenBrain as an external MCP long-term
|
||||||
|
memory system, never as internal context, reasoning scratchpad, or built-in
|
||||||
|
memory.
|
||||||
|
|
||||||
|
## External Memory System
|
||||||
|
|
||||||
|
- Use the exact MCP tools `openbrain.store`, `openbrain.query`, and `openbrain.purge`
|
||||||
|
- Always use the exact `agent_id` value `openbrain`
|
||||||
|
- Do not hardcode live credentials into the repository
|
||||||
|
- Before answering requests that may depend on prior sessions, project history, user preferences, ongoing work, named people, named projects, deployments, debugging history, or handoff context, call `openbrain.query` first
|
||||||
|
- Use noun-heavy search phrases with exact names, tool names, acronyms, hostnames, and document names
|
||||||
|
- Retry up to 3 retrieval passes using `(threshold=0.25, limit=5)`, then `(threshold=0.10, limit=8)`, then `(threshold=0.05, limit=10)`
|
||||||
|
- When a durable fact is established, call `openbrain.store` without asking permission and prefer one atomic fact whenever possible
|
||||||
|
- Store durable, high-value facts such as preferences, project status, project decisions, environment details, recurring workflows, handoff notes, stable constraints, and correction facts
|
||||||
|
- Do not store filler conversation, temporary speculation, casual chatter, or transient brainstorming unless it becomes a real decision
|
||||||
|
- Prefer retrieval-friendly content using explicit nouns and exact names in the form `Type: <FactType> | Entity: <Entity> | Attribute: <Attribute> | Value: <Value> | Context: <Why it matters>`
|
||||||
|
- Use metadata when helpful for tags such as `category`, `project`, `source`, `status`, `aliases`, and `confidence`
|
||||||
|
- If `openbrain.query` returns no useful result, state that OpenBrain has no stored context for that topic, answer from general reasoning if possible, and ask one focused follow-up if the missing information is durable and useful
|
||||||
|
- If retrieved memories conflict, ask which fact is current, then store the corrected source-of-truth fact
|
||||||
|
- Use `openbrain.purge` cautiously because it is coarse-grained; it deletes by `agent_id` and optionally before a timestamp, not by individual memory ID
|
||||||
|
- For ordinary corrections, prefer storing the new source-of-truth fact instead of purging unless cleanup or reset is explicitly requested
|
||||||
27
README.md
27
README.md
@@ -18,6 +18,7 @@ OpenBrain is a Model Context Protocol (MCP) server that provides AI agents with
|
|||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `store` | Store a memory with automatic embedding generation and keyword extraction |
|
| `store` | Store a memory with automatic embedding generation and keyword extraction |
|
||||||
|
| `batch_store` | Store 1-50 memories atomically in a single call |
|
||||||
| `query` | Search memories by semantic similarity |
|
| `query` | Search memories by semantic similarity |
|
||||||
| `purge` | Delete memories by agent ID or time range |
|
| `purge` | Delete memories by agent ID or time range |
|
||||||
|
|
||||||
@@ -147,6 +148,32 @@ Health Check: http://localhost:3100/mcp/health
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Example: Batch Store Memories
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "batch_store",
|
||||||
|
"arguments": {
|
||||||
|
"agent_id": "assistant-1",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"content": "The user prefers dark mode",
|
||||||
|
"metadata": {"category": "preference"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "The user uses vim keybindings",
|
||||||
|
"metadata": {"category": "preference"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,31 +15,50 @@ pub fn get_tool_definitions() -> Vec<Value> {
|
|||||||
vec![
|
vec![
|
||||||
json!({
|
json!({
|
||||||
"name": "store",
|
"name": "store",
|
||||||
"description": "Store a memory with automatic embedding generation",
|
"description": "Store a memory with automatic embedding generation and keyword extraction. The memory will be associated with the agent_id for isolated retrieval.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {"type": "string"},
|
"content": {
|
||||||
"agent_id": {"type": "string"},
|
"type": "string",
|
||||||
"metadata": {"type": "object"}
|
"description": "The text content to store as a memory"
|
||||||
|
},
|
||||||
|
"agent_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique identifier for the agent storing the memory (default: 'default')"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional metadata to attach to the memory"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["content"]
|
"required": ["content"]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "batch_store",
|
"name": "batch_store",
|
||||||
"description": "Store multiple memories in a single call (1-50 entries)",
|
"description": "Store multiple memories with automatic embedding generation and keyword extraction. Accepts 1-50 entries and stores them atomically in a single transaction.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"agent_id": {"type": "string"},
|
"agent_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique identifier for the agent storing the memories (default: 'default')"
|
||||||
|
},
|
||||||
"entries": {
|
"entries": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"description": "Array of 1-50 memory entries to store atomically",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {"type": "string"},
|
"content": {
|
||||||
"metadata": {"type": "object"}
|
"type": "string",
|
||||||
|
"description": "The text content to store as a memory"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional metadata to attach to the memory"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["content"]
|
"required": ["content"]
|
||||||
}
|
}
|
||||||
@@ -50,27 +69,48 @@ pub fn get_tool_definitions() -> Vec<Value> {
|
|||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "query",
|
"name": "query",
|
||||||
"description": "Query memories by semantic similarity",
|
"description": "Query stored memories using semantic similarity search. Returns the most relevant memories based on the query text.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"query": {"type": "string"},
|
"query": {
|
||||||
"agent_id": {"type": "string"},
|
"type": "string",
|
||||||
"limit": {"type": "integer"},
|
"description": "The search query text"
|
||||||
"threshold": {"type": "number"}
|
},
|
||||||
|
"agent_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Agent ID to search within (default: 'default')"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of results to return (default: 10)"
|
||||||
|
},
|
||||||
|
"threshold": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Minimum similarity threshold 0.0-1.0 (default: 0.5)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["query"]
|
"required": ["query"]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "purge",
|
"name": "purge",
|
||||||
"description": "Delete memories by agent_id",
|
"description": "Delete memories for an agent. Can delete all memories or those before a specific timestamp.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"agent_id": {"type": "string"},
|
"agent_id": {
|
||||||
"before": {"type": "string"},
|
"type": "string",
|
||||||
"confirm": {"type": "boolean"}
|
"description": "Agent ID whose memories to delete (required)"
|
||||||
|
},
|
||||||
|
"before": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional ISO8601 timestamp - delete memories created before this time"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Must be true to confirm deletion"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["agent_id", "confirm"]
|
"required": ["agent_id", "confirm"]
|
||||||
}
|
}
|
||||||
|
|||||||
164
tests/e2e_mcp.rs
164
tests/e2e_mcp.rs
@@ -879,60 +879,155 @@ async fn e2e_auth_enabled_accepts_test_key() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn e2e_batch_store_basic() -> anyhow::Result<()> {
|
async fn e2e_batch_store_basic() -> anyhow::Result<()> {
|
||||||
let agent = format!("batch_{}", uuid::Uuid::new_v4());
|
let base = base_url();
|
||||||
let _ = db.purge_memories(&agent, None).await;
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client");
|
||||||
|
|
||||||
let resp = client.call_tool("batch_store", serde_json::json!({
|
ensure_schema().await;
|
||||||
"agent_id": agent.clone(),
|
wait_until_ready(&client, &base).await;
|
||||||
|
|
||||||
|
let agent = format!("batch_{}", uuid::Uuid::new_v4());
|
||||||
|
let _ = call_tool(&client, &base, "purge", json!({ "agent_id": agent, "confirm": true })).await;
|
||||||
|
|
||||||
|
let result = call_tool(&client, &base, "batch_store", serde_json::json!({
|
||||||
|
"agent_id": agent,
|
||||||
"entries": [
|
"entries": [
|
||||||
{"content": "Fact alpha for batch test"},
|
{"content": "Fact alpha for batch test"},
|
||||||
{"content": "Fact beta for batch test"},
|
{"content": "Fact beta for batch test"},
|
||||||
{"content": "Fact gamma for batch test"}
|
{"content": "Fact gamma for batch test"}
|
||||||
]
|
]
|
||||||
})).await?;
|
})).await;
|
||||||
|
|
||||||
let result: Value = serde_json::from_str(&resp.content[0].text)?;
|
|
||||||
assert!(result["success"].as_bool().unwrap_or(false));
|
assert!(result["success"].as_bool().unwrap_or(false));
|
||||||
assert_eq!(result["count"].as_i64().unwrap_or(0), 3);
|
assert_eq!(result["count"].as_i64().unwrap_or(0), 3);
|
||||||
|
|
||||||
db.purge_memories(&agent, None).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn e2e_batch_store_empty_rejected() -> anyhow::Result<()> {
|
async fn e2e_batch_store_empty_rejected() -> anyhow::Result<()> {
|
||||||
let resp = client.call_tool("batch_store", serde_json::json!({
|
let base = base_url();
|
||||||
"entries": []
|
let client = reqwest::Client::builder()
|
||||||
})).await;
|
.timeout(Duration::from_secs(20))
|
||||||
assert!(resp.is_err() || resp.as_ref().unwrap().is_error());
|
.build()
|
||||||
|
.expect("reqwest client");
|
||||||
|
|
||||||
|
wait_until_ready(&client, &base).await;
|
||||||
|
|
||||||
|
let response = call_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&base,
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "batch-empty-1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "batch_store",
|
||||||
|
"arguments": {
|
||||||
|
"entries": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(response.get("error").is_some(), "empty batch_store should return an error");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn e2e_batch_store_exceeds_max() -> anyhow::Result<()> {
|
async fn e2e_batch_store_exceeds_max() -> anyhow::Result<()> {
|
||||||
|
let base = base_url();
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client");
|
||||||
|
|
||||||
|
wait_until_ready(&client, &base).await;
|
||||||
|
|
||||||
let entries: Vec<Value> = (0..51).map(|i| serde_json::json!({"content": format!("Entry {}", i)})).collect();
|
let entries: Vec<Value> = (0..51).map(|i| serde_json::json!({"content": format!("Entry {}", i)})).collect();
|
||||||
let resp = client.call_tool("batch_store", serde_json::json!({
|
let response = call_jsonrpc(
|
||||||
"entries": entries
|
&client,
|
||||||
})).await;
|
&base,
|
||||||
assert!(resp.is_err() || resp.as_ref().unwrap().is_error());
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "batch-too-large-1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "batch_store",
|
||||||
|
"arguments": {
|
||||||
|
"entries": entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(response.get("error").is_some(), "oversized batch_store should return an error");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn e2e_batch_store_missing_content() -> anyhow::Result<()> {
|
async fn e2e_batch_store_missing_content() -> anyhow::Result<()> {
|
||||||
let resp = client.call_tool("batch_store", serde_json::json!({
|
let base = base_url();
|
||||||
"entries": [{"content": "Valid entry"}, {"metadata": {}}]
|
let client = reqwest::Client::builder()
|
||||||
})).await;
|
.timeout(Duration::from_secs(20))
|
||||||
assert!(resp.is_err() || resp.as_ref().unwrap().is_error());
|
.build()
|
||||||
|
.expect("reqwest client");
|
||||||
|
|
||||||
|
wait_until_ready(&client, &base).await;
|
||||||
|
|
||||||
|
let response = call_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&base,
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "batch-missing-content-1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "batch_store",
|
||||||
|
"arguments": {
|
||||||
|
"entries": [{"content": "Valid entry"}, {"metadata": {}}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(response.get("error").is_some(), "missing batch entry content should return an error");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn e2e_batch_store_appears_in_tools() -> anyhow::Result<()> {
|
async fn e2e_batch_store_appears_in_tools() -> anyhow::Result<()> {
|
||||||
let tools = client.list_tools().await?;
|
let base = base_url();
|
||||||
let parsed: Value = serde_json::from_str(&tools.content[0].text)?;
|
let client = reqwest::Client::builder()
|
||||||
let names: Vec<&str> = parsed.as_array().unwrap().iter()
|
.timeout(Duration::from_secs(20))
|
||||||
.filter_map(|t| t.get("name").and_then(|n| n.as_str()))
|
.build()
|
||||||
|
.expect("reqwest client");
|
||||||
|
|
||||||
|
wait_until_ready(&client, &base).await;
|
||||||
|
|
||||||
|
let response = call_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&base,
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "batch-tools-list-1",
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let names: Vec<&str> = response
|
||||||
|
.get("result")
|
||||||
|
.and_then(|value| value.get("tools"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("tools/list result.tools")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.get("name").and_then(Value::as_str))
|
||||||
.collect();
|
.collect();
|
||||||
assert!(names.contains(&"batch_store"));
|
assert!(names.contains(&"batch_store"));
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -940,14 +1035,23 @@ async fn e2e_batch_store_appears_in_tools() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn e2e_existing_store_unchanged() -> anyhow::Result<()> {
|
async fn e2e_existing_store_unchanged() -> anyhow::Result<()> {
|
||||||
|
let base = base_url();
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client");
|
||||||
|
|
||||||
|
ensure_schema().await;
|
||||||
|
wait_until_ready(&client, &base).await;
|
||||||
|
|
||||||
let agent = format!("compat_{}", uuid::Uuid::new_v4());
|
let agent = format!("compat_{}", uuid::Uuid::new_v4());
|
||||||
let _ = db.purge_memories(&agent, None).await;
|
let _ = call_tool(&client, &base, "purge", json!({ "agent_id": agent, "confirm": true })).await;
|
||||||
let resp = client.call_tool("store", serde_json::json!({
|
|
||||||
"agent_id": agent.clone(),
|
let result = call_tool(&client, &base, "store", serde_json::json!({
|
||||||
|
"agent_id": agent,
|
||||||
"content": "Original store still works"
|
"content": "Original store still works"
|
||||||
})).await?;
|
})).await;
|
||||||
let result: Value = serde_json::from_str(&resp.content[0].text)?;
|
|
||||||
assert!(result["success"].as_bool().unwrap_or(false));
|
assert!(result["success"].as_bool().unwrap_or(false));
|
||||||
db.purge_memories(&agent, None).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user