Skip to content
Open
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
58 changes: 22 additions & 36 deletions ci/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,47 +84,33 @@ def main() -> None:
choices=["major", "minor", "patch", "pre_label", "pre_n"],
help="Bump a specific component",
)
parser.add_argument("--dry-run", action="store_true", help="Show changes without modifying files")

parser.add_argument("--dry-run", action="store_true", help="Preview changes without modifying files")
args = parser.parse_args()

current = get_current_version()
print(f"Current version: {current}")

base_cmd = ["bump-my-version", "bump"]
if args.version:
new_version = args.version
elif args.bump:
current_version = get_current_version()
if args.bump == "major":
new_version = f"{int(parse_version(current_version)['major']) + 1}.0.0"
elif args.bump == "minor":
new_version = f"{parse_version(current_version)['major']}.{int(parse_version(current_version)['minor']) + 1}.0"
elif args.bump == "patch":
new_version = f"{parse_version(current_version)['major']}.{parse_version(current_version)['minor']}.{int(parse_version(current_version)['patch']) + 1}"
elif args.bump == "pre_label":
new_version = f"{current_version}-alpha.1" if not is_prerelease(current_version) else f"{current_version.split('-')[0]}-alpha.1"
elif args.bump == "pre_n":
if not is_prerelease(current_version):
new_version = f"{current_version}-alpha.1"
else:
pre_label, pre_n = current_version.split('-')[1].split('.')
new_version = f"{current_version.split('-')[0]}-{pre_label}.{int(pre_n) + 1}"

if args.dry_run:
print("\nDry run mode - no changes will be made")
base_cmd.extend(["--dry-run", "--verbose"])
print(f"Dry run: New version would be {new_version}")
else:
base_cmd.extend(["--no-commit", "--no-tag"])

base_cmd.append("--allow-dirty")

if args.version:
target = args.version
print(f"Target version: {target}")
try:
parse_version(target)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)

cmd = base_cmd + ["--current-version", current, "--new-version", target]
else:
bump_part = args.bump
if bump_part in {"pre_n", "pre_label"} and not is_prerelease(current):
print(f"Error: Cannot bump '{bump_part}' on stable version {current}.")
print("Use --version to move to a pre-release first.")
sys.exit(1)
cmd = base_cmd + [bump_part]

run_command(cmd, capture_output=False)

if not args.dry_run:
updated = get_current_version()
print(f"\nSuccessfully updated version from {current} to {updated}")
run_command(["bump-my-version", new_version])


if __name__ == "__main__":
main()
main()
80 changes: 80 additions & 0 deletions crates/lance-context-server/src/routes/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,86 @@ mod tests {
assert!(results.iter().any(|r| r.record.id == old_id));
}

#[tokio::test]
async fn search_respects_explicit_retirement() {
let (state, _dir) = test_state().await;

let mut record = embedded_record("Explicit retire", [1.0, 0.0, 0.0]);
record.external_id = Some("doc-2".to_string());
add(&state, vec![record]).await;

// Patch to retired_at
let patch_req = UpdateRecordRequest {
id: None,
external_id: Some("doc-2".to_string()),
patch: RecordPatchDto {
retired_at: Some(Some(Utc::now())),
..Default::default()
},
};
crate::routes::records::update_record(
axum::extract::State(state.clone()),
axum::extract::Path("test-ctx".to_string()),
axum::Json(patch_req),
)
.await
.unwrap();

// Default search hides the retired record.
let results = run_search(&state, search_for([1.0, 0.0, 0.0])).await;
assert_eq!(results.len(), 0);

// include_retired surfaces it.
let mut req = search_for([1.0, 0.0, 0.0]);
req.include_retired = true;
let results = run_search(&state, req).await;
assert_eq!(results.len(), 1);
assert_eq!(
results[0].record.text_payload.as_deref(),
Some("Explicit retire")
);
}

#[tokio::test]
async fn search_respects_non_active_lifecycle_status() {
let (state, _dir) = test_state().await;

let mut record = embedded_record("Non-active", [1.0, 0.0, 0.0]);
record.external_id = Some("doc-3".to_string());
add(&state, vec![record]).await;

// Patch to contradicted
let patch_req = UpdateRecordRequest {
id: None,
external_id: Some("doc-3".to_string()),
patch: RecordPatchDto {
lifecycle_status: Some(Some(crate::model::LifecycleStatus::Contradicted)),
..Default::default()
},
};
crate::routes::records::update_record(
axum::extract::State(state.clone()),
axum::extract::Path("test-ctx".to_string()),
axum::Json(patch_req),
)
.await
.unwrap();

// Default search hides the contradicted record.
let results = run_search(&state, search_for([1.0, 0.0, 0.0])).await;
assert_eq!(results.len(), 0);

// include_retired surfaces it.
let mut req = search_for([1.0, 0.0, 0.0]);
req.include_retired = true;
let results = run_search(&state, req).await;
assert_eq!(results.len(), 1);
assert_eq!(
results[0].record.text_payload.as_deref(),
Some("Non-active")
);
}

#[tokio::test]
async fn search_include_relationships_toggles_relationship_payload() {
let (state, _dir) = test_state().await;
Expand Down
116 changes: 6 additions & 110 deletions examples/mcp-claude-code/mcp_smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import shutil
import sys
from pathlib import Path
from typing import Any, Iterable
from typing import Any, Iterable, Optional
from datetime import datetime

from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
Expand All @@ -24,14 +25,14 @@ def _default_uri() -> Path:
return _example_root() / ".artifacts" / "e2e_context.lance"


def _coerce_text_blocks(blocks: Iterable[Any]) -> str | None:
def _coerce_text_blocks(blocks: Iterable[Any]) -> Optional[str]:
for block in blocks:
if getattr(block, "type", None) == "text" and hasattr(block, "text"):
return block.text
return None


def _payload_from_result(result: Any) -> Any | None:
def _payload_from_result(result: Any) -> Optional[Any]:
if getattr(result, "structuredContent", None) is not None:
return result.structuredContent
text = _coerce_text_blocks(getattr(result, "content", []))
Expand All @@ -57,7 +58,7 @@ def _extract_list(payload: Any, tool_name: str) -> list[dict[str, Any]]:
raise ToolError(f"{tool_name} returned unexpected payload: {payload!r}")


async def _call_tool(session: ClientSession, name: str, arguments: dict[str, Any] | None = None) -> Any:
async def _call_tool(session: ClientSession, name: str, arguments: Optional[dict[str, Any]] = None) -> Any:
result = await session.call_tool(name, arguments)
if result.isError:
message = _coerce_text_blocks(result.content) or "unknown error"
Expand Down Expand Up @@ -94,109 +95,4 @@ async def run_e2e(uri: Path, keep: bool) -> None:
initial_entries = stats_payload["entries"]
_print_step(f"Initial stats: entries={initial_entries}, version={initial_version}")

_print_step("Adding memory + knowledge entries")
await _call_tool(
session,
"add_entry",
{
"role": "user",
"content": "Remember: I like single-origin coffee.",
"session_id": "profile",
},
)
await _call_tool(
session,
"add_entry",
{
"role": "assistant",
"content": "The enterprise support SLA is 24 hours.",
"session_id": "policy",
},
)
await _call_tool(
session,
"add_entry",
{
"role": "assistant",
"content": "Project Nebula rollout is scheduled for May 2026.",
"session_id": "roadmap",
},
)

stats_after_add = _require_dict(
_payload_from_result(await _call_tool(session, "stats")), "stats"
)
if stats_after_add["entries"] != 3:
raise ToolError("Expected 3 entries after initial adds")
_print_step(f"Stats after adds: entries={stats_after_add['entries']}")

listed = _extract_list(
_payload_from_result(await _call_tool(session, "list_entries", {"limit": 10})),
"list_entries",
)
if len(listed) != 3:
raise ToolError(f"Expected 3 list_entries results, got {len(listed)}")
_print_step("Listed entries successfully")

matches = _extract_list(
_payload_from_result(await _call_tool(session, "search_entries", {"query": "coffee"})),
"search_entries",
)
if not any("coffee" in (entry.get("text") or "").lower() for entry in matches):
raise ToolError("Expected search_entries to find 'coffee'")
_print_step("Search returned expected memory match")

_print_step("Adding a temporary entry to test checkout")
await _call_tool(
session,
"add_entry",
{
"role": "assistant",
"content": "Temporary note: remove me after rollback.",
"session_id": "temp",
},
)
stats_after_temp = _require_dict(
_payload_from_result(await _call_tool(session, "stats")), "stats"
)
if stats_after_temp["entries"] != 4:
raise ToolError("Expected 4 entries after temp add")

_print_step(f"Checkout version {stats_after_add['version']}")
await _call_tool(session, "checkout_version", {"version_id": stats_after_add["version"]})
stats_after_checkout = _require_dict(
_payload_from_result(await _call_tool(session, "stats")), "stats"
)
if stats_after_checkout["entries"] != 3:
raise ToolError("Expected 3 entries after checkout")

_print_step("E2E assertions passed")
finally:
if not keep and uri.exists():
shutil.rmtree(uri)

if keep:
print(f"E2E OK. Dataset: {uri}")
else:
print("E2E OK. Dataset removed (use --keep to inspect).")


def main() -> None:
parser = argparse.ArgumentParser(description="Run MCP end-to-end test against lance-context.")
parser.add_argument(
"--uri",
type=Path,
default=_default_uri(),
help="Lance dataset URI for the test.",
)
parser.add_argument(
"--keep",
action="store_true",
help="Keep the dataset on disk after the test.",
)
args = parser.parse_args()
asyncio.run(run_e2e(args.uri, args.keep))


if __name__ == "__main__":
main()
_print_step("Adding memory + knowledge
30 changes: 5 additions & 25 deletions examples/mcp-claude-code/server/lance_context_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, Optional

from lance_context import Context
from mcp.server.fastmcp import FastMCP
Expand All @@ -13,7 +13,7 @@

mcp = FastMCP("Lance Context")

_CTX: Context | None = None
_CTX: Optional[Context] = None


def _default_uri() -> str:
Expand Down Expand Up @@ -65,8 +65,8 @@ def add_entry(
role: str,
content: str,
*,
session_id: str | None = None,
bot_id: str | None = None,
session_id: Optional[str] = None,
bot_id: Optional[str] = None,
) -> dict[str, Any]:
"""Store a memory or knowledge entry in the context store."""
ctx = _require_context()
Expand Down Expand Up @@ -109,24 +109,4 @@ def checkout_version(version_id: int) -> dict[str, Any]:
def stats() -> dict[str, Any]:
"""Return dataset stats (entries, version, branch, uri)."""
ctx = _require_context()
return _stats(ctx)


def main() -> None:
parser = argparse.ArgumentParser(
description="MCP server exposing lance-context as memory and knowledge base."
)
parser.add_argument(
"--uri",
default=os.getenv("LANCE_CONTEXT_URI"),
help="Lance context dataset URI (defaults to .artifacts/claude_context.lance)",
)
args = parser.parse_args()

uri = args.uri or _default_uri()
init_context(uri)
mcp.run()


if __name__ == "__main__":
main()
return _stats(ctx)
Loading