From f2d965294c9857c164342f05e929a71bb418e8e3 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 11:42:47 -0600 Subject: [PATCH 01/16] Add root endpoint for MCP server --- README.md | 1 + src/digital_persona/mcp_server.py | 5 +++++ tests/test_mcp_server.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 0ff0390..2145527 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. + - The MCP server runs on `http://localhost:8900`. Visit `/docs` for API documentation or call `/limitless/lifelogs` when `LIMITLESS_API_KEY` is set to fetch your Limitless lifelogs. ### Sample Data diff --git a/src/digital_persona/mcp_server.py b/src/digital_persona/mcp_server.py index 142a08a..e922bcb 100644 --- a/src/digital_persona/mcp_server.py +++ b/src/digital_persona/mcp_server.py @@ -19,6 +19,11 @@ def create_app(plugin_names: list[str] | None = None) -> FastAPI: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") app = FastAPI(title="MCP Server") + + @app.get("/", include_in_schema=False) + def root() -> dict: + """Basic health check message.""" + return {"message": "MCP server running", "docs": "/docs"} for name in plugin_names: try: mod = importlib.import_module(name) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index ac26ad3..2058604 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -19,3 +19,17 @@ def test_limitless_route(monkeypatch, tmp_path: Path): resp = client.get("/limitless/lifelogs") assert resp.status_code == 200 assert resp.json()["items"][0]["content"] == "hi" + + +def test_root_route(monkeypatch, tmp_path: Path): + monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) + monkeypatch.setenv("MCP_PLUGINS", "") + + from digital_persona import mcp_server + importlib.reload(mcp_server) + app = mcp_server.create_app([]) + client = TestClient(app) + + resp = client.get("/") + assert resp.status_code == 200 + assert resp.json()["message"].startswith("MCP server") From c454d8b8586c40f7a8fa799497867807a2751079 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 11:56:03 -0600 Subject: [PATCH 02/16] Make MCP plugins POST endpoints --- README.md | 2 +- src/digital_persona/mcp_plugins/limitless.py | 28 +++++++++++++++----- tests/test_mcp_server.py | 4 +-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2145527..d1c8739 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/docs` for API documentation or call `/limitless/lifelogs` when `LIMITLESS_API_KEY` is set to fetch your Limitless lifelogs. + - The MCP server runs on `http://localhost:8900`. Visit `/docs` for API docs or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` to fetch your Limitless lifelogs. ### Sample Data diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index be72557..05db9cd 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -4,7 +4,8 @@ from datetime import datetime, timedelta, UTC import asyncio import httpx -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, FastAPI, Depends, Security +from fastapi.security import APIKeyQuery from digital_persona.utils.filename import sanitize_filename from digital_persona import config as dp_config @@ -28,6 +29,13 @@ router = APIRouter() +api_key_query = APIKeyQuery(name="api_key", auto_error=False) + + +def get_api_key(api_key: str | None = Security(api_key_query)) -> str: + """Return *api_key* or the environment variable value.""" + return api_key or API_KEY + def setup(app: FastAPI) -> None: """Attach background ingest task to *app* startup.""" @@ -54,10 +62,10 @@ def _save_state(state: dict) -> None: STATE_FILE.write_text(json.dumps(state)) -def _fetch_entries(*, start: str | None = None, cursor: str | None = None) -> tuple[list[dict], str | None]: +def _fetch_entries(*, start: str | None = None, cursor: str | None = None, api_key: str = API_KEY) -> tuple[list[dict], str | None]: """Return lifelog entries and the next cursor.""" - headers = {"X-API-Key": API_KEY} + headers = {"X-API-Key": api_key} params = {} if start: params["start"] = start @@ -132,10 +140,18 @@ def run_once() -> None: _save_state(state) -@router.get("/lifelogs") -async def api_lifelogs(start: str | None = None, cursor: str | None = None) -> dict: +@router.post( + "/lifelogs", + name="limitless_lifelogs", + description="Fetch Limitless lifelog entries", +) +async def api_lifelogs( + start: str | None = None, + cursor: str | None = None, + api_key: str = Depends(get_api_key), +) -> dict: """Return Limitless entries via the MCP server.""" - items, next_cursor = _fetch_entries(start=start, cursor=cursor) + items, next_cursor = _fetch_entries(start=start, cursor=cursor, api_key=api_key) return {"items": items, "next_cursor": next_cursor} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 2058604..4ae9580 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -10,13 +10,13 @@ def test_limitless_route(monkeypatch, tmp_path: Path): monkeypatch.setenv("MCP_PLUGINS", "digital_persona.mcp_plugins.limitless") import digital_persona.mcp_plugins.limitless as limitless - monkeypatch.setattr(limitless, "_fetch_entries", lambda start=None, cursor=None: ([{"id": "1", "content": "hi"}], None)) + monkeypatch.setattr(limitless, "_fetch_entries", lambda start=None, cursor=None, api_key="x": ([{"id": "1", "content": "hi"}], None)) from digital_persona import mcp_server importlib.reload(mcp_server) app = mcp_server.create_app() client = TestClient(app) - resp = client.get("/limitless/lifelogs") + resp = client.post("/limitless/lifelogs?api_key=x") assert resp.status_code == 200 assert resp.json()["items"][0]["content"] == "hi" From 4b57b1514cc2f687ec532d104f7eb77f39dff017 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:13:12 -0600 Subject: [PATCH 03/16] Use underscore title for OpenAPI --- README.md | 2 +- src/digital_persona/mcp_server.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d1c8739..9342d17 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/docs` for API docs or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` to fetch your Limitless lifelogs. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` to fetch your Limitless lifelogs. ### Sample Data diff --git a/src/digital_persona/mcp_server.py b/src/digital_persona/mcp_server.py index e922bcb..7010bff 100644 --- a/src/digital_persona/mcp_server.py +++ b/src/digital_persona/mcp_server.py @@ -18,7 +18,8 @@ def create_app(plugin_names: list[str] | None = None) -> FastAPI: if not logger.handlers: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") - app = FastAPI(title="MCP Server") + # info.title becomes the plugin ID in some UIs, so avoid spaces + app = FastAPI(title="mcp_server") @app.get("/", include_in_schema=False) def root() -> dict: From 3dbc81adbe2ea3653229340f62018e73ab7c60a6 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:36:50 -0600 Subject: [PATCH 04/16] Specify operation_id for Limitless route --- src/digital_persona/mcp_plugins/limitless.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index 05db9cd..850779c 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -144,6 +144,7 @@ def run_once() -> None: "/lifelogs", name="limitless_lifelogs", description="Fetch Limitless lifelog entries", + operation_id="limitless_lifelogs", ) async def api_lifelogs( start: str | None = None, From 30a8d4e8a1ff2ce0ff3ed22edcbfb4cc0ef47989 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 16:22:14 -0600 Subject: [PATCH 05/16] Move lifelogs params to POST body --- README.md | 2 +- src/digital_persona/mcp_plugins/limitless.py | 13 +++++++++++-- tests/test_mcp_server.py | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9342d17..1c8d064 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` to fetch your Limitless lifelogs. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` with a JSON body containing optional `start` and `cursor` fields to fetch your Limitless lifelogs. ### Sample Data diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index 850779c..098bb55 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -6,6 +6,7 @@ import httpx from fastapi import APIRouter, FastAPI, Depends, Security from fastapi.security import APIKeyQuery +from pydantic import BaseModel from digital_persona.utils.filename import sanitize_filename from digital_persona import config as dp_config @@ -32,6 +33,13 @@ api_key_query = APIKeyQuery(name="api_key", auto_error=False) +class LifelogParams(BaseModel): + """Parameters accepted by the lifelogs endpoint.""" + + start: str | None = None + cursor: str | None = None + + def get_api_key(api_key: str | None = Security(api_key_query)) -> str: """Return *api_key* or the environment variable value.""" return api_key or API_KEY @@ -147,11 +155,12 @@ def run_once() -> None: operation_id="limitless_lifelogs", ) async def api_lifelogs( - start: str | None = None, - cursor: str | None = None, + params: LifelogParams | None = None, api_key: str = Depends(get_api_key), ) -> dict: """Return Limitless entries via the MCP server.""" + start = params.start if params else None + cursor = params.cursor if params else None items, next_cursor = _fetch_entries(start=start, cursor=cursor, api_key=api_key) return {"items": items, "next_cursor": next_cursor} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 4ae9580..6c0a90d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -16,7 +16,7 @@ def test_limitless_route(monkeypatch, tmp_path: Path): importlib.reload(mcp_server) app = mcp_server.create_app() client = TestClient(app) - resp = client.post("/limitless/lifelogs?api_key=x") + resp = client.post("/limitless/lifelogs?api_key=x", json={}) assert resp.status_code == 200 assert resp.json()["items"][0]["content"] == "hi" From 3e61a0c278f3d4f89e9fbe0ec1f9519588c98f2f Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 16:38:51 -0600 Subject: [PATCH 06/16] Handle invalid lifelog params --- README.md | 2 +- src/digital_persona/mcp_plugins/limitless.py | 26 ++++++++-- tests/test_mcp_server.py | 50 ++++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1c8d064..ce5bb04 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` with a JSON body containing optional `start` and `cursor` fields to fetch your Limitless lifelogs. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` with a JSON body containing optional `start` and `cursor` fields to fetch your Limitless lifelogs. Leave these fields empty if you don't have real values—some tools may send the string `"string"`, which the server now ignores. ### Sample Data diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index 098bb55..3305d46 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -4,9 +4,9 @@ from datetime import datetime, timedelta, UTC import asyncio import httpx -from fastapi import APIRouter, FastAPI, Depends, Security +from fastapi import APIRouter, FastAPI, Depends, Security, HTTPException from fastapi.security import APIKeyQuery -from pydantic import BaseModel +from pydantic import BaseModel, Field from digital_persona.utils.filename import sanitize_filename from digital_persona import config as dp_config @@ -36,8 +36,16 @@ class LifelogParams(BaseModel): """Parameters accepted by the lifelogs endpoint.""" - start: str | None = None - cursor: str | None = None + start: str | None = Field( + default=None, + description="ISO 8601 timestamp to fetch entries after", + examples=["2025-07-19T22:00:00Z"], + ) + cursor: str | None = Field( + default=None, + description="Pagination cursor returned from previous call", + examples=["eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"], + ) def get_api_key(api_key: str | None = Security(api_key_query)) -> str: @@ -161,7 +169,15 @@ async def api_lifelogs( """Return Limitless entries via the MCP server.""" start = params.start if params else None cursor = params.cursor if params else None - items, next_cursor = _fetch_entries(start=start, cursor=cursor, api_key=api_key) + # OpenAPI tooling may send literal "string" when no value is provided + if start == "string": + start = None + if cursor == "string": + cursor = None + try: + items, next_cursor = _fetch_entries(start=start, cursor=cursor, api_key=api_key) + except httpx.HTTPStatusError as exc: + raise HTTPException(status_code=exc.response.status_code, detail=exc.response.text) return {"items": items, "next_cursor": next_cursor} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 6c0a90d..3023ed3 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -21,6 +21,56 @@ def test_limitless_route(monkeypatch, tmp_path: Path): assert resp.json()["items"][0]["content"] == "hi" +def test_limitless_ignores_string_params(monkeypatch, tmp_path: Path): + monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) + monkeypatch.setenv("LIMITLESS_API_KEY", "x") + monkeypatch.setenv("MCP_PLUGINS", "digital_persona.mcp_plugins.limitless") + + import digital_persona.mcp_plugins.limitless as limitless + + def fake_fetch(start=None, cursor=None, api_key="x"): + assert start is None + assert cursor is None + return [{"id": "1"}], None + + monkeypatch.setattr(limitless, "_fetch_entries", fake_fetch) + + from digital_persona import mcp_server + importlib.reload(mcp_server) + app = mcp_server.create_app() + client = TestClient(app) + + resp = client.post( + "/limitless/lifelogs?api_key=x", json={"start": "string", "cursor": "string"} + ) + assert resp.status_code == 200 + assert resp.json()["items"][0]["id"] == "1" + + +def test_limitless_http_error(monkeypatch, tmp_path: Path): + monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) + monkeypatch.setenv("LIMITLESS_API_KEY", "x") + monkeypatch.setenv("MCP_PLUGINS", "digital_persona.mcp_plugins.limitless") + + import digital_persona.mcp_plugins.limitless as limitless + import httpx + + def fake_fetch(**_): + request = httpx.Request("GET", "http://x") + response = httpx.Response(400, request=request, text="bad") + raise httpx.HTTPStatusError("bad", request=request, response=response) + + monkeypatch.setattr(limitless, "_fetch_entries", fake_fetch) + + from digital_persona import mcp_server + importlib.reload(mcp_server) + app = mcp_server.create_app() + client = TestClient(app) + + resp = client.post("/limitless/lifelogs?api_key=x", json={}) + assert resp.status_code == 400 + + def test_root_route(monkeypatch, tmp_path: Path): monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) monkeypatch.setenv("MCP_PLUGINS", "") From e894984d92fa4135f8b07e46df0315ee64d6e80a Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 17:53:48 -0600 Subject: [PATCH 07/16] Serve lifelogs from local store --- README.md | 2 +- src/digital_persona/mcp_plugins/limitless.py | 47 +++++++++++++++++--- tests/test_mcp_server.py | 20 +++++---- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ce5bb04..ede8020 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` with a JSON body containing optional `start` and `cursor` fields to fetch your Limitless lifelogs. Leave these fields empty if you don't have real values—some tools may send the string `"string"`, which the server now ignores. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` with a JSON body containing optional `start` and `cursor` fields to read previously fetched Limitless lifelogs from `PERSONA_DIR/input`. Placeholder values like `"string"` are ignored. ### Sample Data diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index 3305d46..e68a7db 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta, UTC import asyncio import httpx -from fastapi import APIRouter, FastAPI, Depends, Security, HTTPException +from fastapi import APIRouter, FastAPI, Depends, Security from fastapi.security import APIKeyQuery from pydantic import BaseModel, Field from digital_persona.utils.filename import sanitize_filename @@ -116,6 +116,46 @@ def _get_entry_filename(entry_id: str) -> os.PathLike: return INPUT_DIR / f"limitless-{entry_id}.json" +def _load_local_entries( + *, start: str | None = None, cursor: str | None = None, limit: int = 100 +) -> tuple[list[dict], str | None]: + """Return stored Limitless entries from ``INPUT_DIR``.""" + + files = sorted(INPUT_DIR.glob("limitless-*.json")) + start_dt = None + if start: + try: + start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) + except Exception: + start_dt = None + index = 0 + if cursor: + try: + index = int(cursor) + except Exception: + index = 0 + items: list[dict] = [] + next_cursor: str | None = None + for idx, path in enumerate(files[index:], start=index): + try: + obj = json.loads(path.read_text()) + except Exception: + continue + ts = obj.get("updatedAt") or obj.get("timestamp") or obj.get("endTime") + if start_dt and ts: + try: + ts_dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) + if ts_dt <= start_dt: + continue + except Exception: + pass + items.append(obj) + if len(items) >= limit: + next_cursor = str(idx + 1) + break + return items, next_cursor + + def run_once() -> None: state = _load_state() last_id: str | None = state.get("last_id") @@ -174,10 +214,7 @@ async def api_lifelogs( start = None if cursor == "string": cursor = None - try: - items, next_cursor = _fetch_entries(start=start, cursor=cursor, api_key=api_key) - except httpx.HTTPStatusError as exc: - raise HTTPException(status_code=exc.response.status_code, detail=exc.response.text) + items, next_cursor = _load_local_entries(start=start, cursor=cursor) return {"items": items, "next_cursor": next_cursor} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 3023ed3..dfcdb23 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -10,7 +10,11 @@ def test_limitless_route(monkeypatch, tmp_path: Path): monkeypatch.setenv("MCP_PLUGINS", "digital_persona.mcp_plugins.limitless") import digital_persona.mcp_plugins.limitless as limitless - monkeypatch.setattr(limitless, "_fetch_entries", lambda start=None, cursor=None, api_key="x": ([{"id": "1", "content": "hi"}], None)) + monkeypatch.setattr( + limitless, + "_load_local_entries", + lambda start=None, cursor=None: ([{"id": "1", "content": "hi"}], None), + ) from digital_persona import mcp_server importlib.reload(mcp_server) @@ -28,12 +32,12 @@ def test_limitless_ignores_string_params(monkeypatch, tmp_path: Path): import digital_persona.mcp_plugins.limitless as limitless - def fake_fetch(start=None, cursor=None, api_key="x"): + def fake_load(start=None, cursor=None): assert start is None assert cursor is None return [{"id": "1"}], None - monkeypatch.setattr(limitless, "_fetch_entries", fake_fetch) + monkeypatch.setattr(limitless, "_load_local_entries", fake_load) from digital_persona import mcp_server importlib.reload(mcp_server) @@ -53,14 +57,12 @@ def test_limitless_http_error(monkeypatch, tmp_path: Path): monkeypatch.setenv("MCP_PLUGINS", "digital_persona.mcp_plugins.limitless") import digital_persona.mcp_plugins.limitless as limitless - import httpx + from fastapi import HTTPException - def fake_fetch(**_): - request = httpx.Request("GET", "http://x") - response = httpx.Response(400, request=request, text="bad") - raise httpx.HTTPStatusError("bad", request=request, response=response) + def fake_load(**_): + raise HTTPException(status_code=400, detail="bad") - monkeypatch.setattr(limitless, "_fetch_entries", fake_fetch) + monkeypatch.setattr(limitless, "_load_local_entries", fake_load) from digital_persona import mcp_server importlib.reload(mcp_server) From db561cd77aaeb8a0091ad216d50b201dc1559fc8 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 17:53:53 -0600 Subject: [PATCH 08/16] Allow keyword search for local lifelogs --- README.md | 2 +- src/digital_persona/mcp_plugins/limitless.py | 83 ++++++++++++-------- tests/test_mcp_server.py | 21 ++--- 3 files changed, 62 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ede8020..7d47b50 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs?api_key=YOUR_KEY` with a JSON body containing optional `start` and `cursor` fields to read previously fetched Limitless lifelogs from `PERSONA_DIR/input`. Placeholder values like `"string"` are ignored. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, and `keyword` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/input`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. ### Sample Data diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index e68a7db..a680675 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -4,16 +4,23 @@ from datetime import datetime, timedelta, UTC import asyncio import httpx -from fastapi import APIRouter, FastAPI, Depends, Security +from fastapi import APIRouter, FastAPI, Security from fastapi.security import APIKeyQuery from pydantic import BaseModel, Field from digital_persona.utils.filename import sanitize_filename +from digital_persona.secure_storage import ( + get_fernet, + save_json_encrypted, + load_json_encrypted, +) from digital_persona import config as dp_config dp_config.load_env() from digital_persona.ingest import INPUT_DIR, _persona_dir +FERNET = get_fernet(_persona_dir()) + STATE_FILE = _persona_dir() / "limitless_state.json" API_URL = os.getenv("LIMITLESS_API_URL", "https://api.limitless.ai/v1") API_KEY = os.getenv("LIMITLESS_API_KEY") @@ -38,20 +45,19 @@ class LifelogParams(BaseModel): start: str | None = Field( default=None, - description="ISO 8601 timestamp to fetch entries after", - examples=["2025-07-19T22:00:00Z"], + description="ISO 8601 start timestamp", + examples=["2025-07-19T00:00:00Z"], ) - cursor: str | None = Field( + end: str | None = Field( default=None, - description="Pagination cursor returned from previous call", - examples=["eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"], + description="ISO 8601 end timestamp", + examples=["2025-07-20T00:00:00Z"], + ) + keyword: str | None = Field( + default=None, + description="Filter entries containing this text", + examples=["meeting"], ) - - -def get_api_key(api_key: str | None = Security(api_key_query)) -> str: - """Return *api_key* or the environment variable value.""" - return api_key or API_KEY - def setup(app: FastAPI) -> None: """Attach background ingest task to *app* startup.""" @@ -108,7 +114,7 @@ def _save_entry(entry: dict) -> None: entry_id = sanitize_filename(str(entry_id)) out = _get_entry_filename(entry_id) obj = {k: v for k, v in entry.items()} - out.write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8") + save_json_encrypted(obj, out, FERNET) logger.info("Saved %s", out.name) def _get_entry_filename(entry_id: str) -> os.PathLike: @@ -116,44 +122,52 @@ def _get_entry_filename(entry_id: str) -> os.PathLike: return INPUT_DIR / f"limitless-{entry_id}.json" -def _load_local_entries( - *, start: str | None = None, cursor: str | None = None, limit: int = 100 -) -> tuple[list[dict], str | None]: +def _search_local_entries( + *, start: str | None = None, + end: str | None = None, + keyword: str | None = None, + limit: int = 100, +) -> list[dict]: """Return stored Limitless entries from ``INPUT_DIR``.""" files = sorted(INPUT_DIR.glob("limitless-*.json")) start_dt = None + end_dt = None if start: try: start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) except Exception: start_dt = None - index = 0 - if cursor: + if end: try: - index = int(cursor) + end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")) except Exception: - index = 0 + end_dt = None + items: list[dict] = [] - next_cursor: str | None = None - for idx, path in enumerate(files[index:], start=index): + for path in files: try: - obj = json.loads(path.read_text()) + obj = load_json_encrypted(path, FERNET) except Exception: continue ts = obj.get("updatedAt") or obj.get("timestamp") or obj.get("endTime") - if start_dt and ts: + if ts: try: ts_dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) - if ts_dt <= start_dt: + if start_dt and ts_dt < start_dt: + continue + if end_dt and ts_dt > end_dt: continue except Exception: pass + if keyword: + text = json.dumps(obj, ensure_ascii=False).lower() + if keyword.lower() not in text: + continue items.append(obj) if len(items) >= limit: - next_cursor = str(idx + 1) break - return items, next_cursor + return items def run_once() -> None: @@ -204,18 +218,21 @@ def run_once() -> None: ) async def api_lifelogs( params: LifelogParams | None = None, - api_key: str = Depends(get_api_key), + api_key: str | None = Security(api_key_query), ) -> dict: """Return Limitless entries via the MCP server.""" start = params.start if params else None - cursor = params.cursor if params else None + end = params.end if params else None + keyword = params.keyword if params else None # OpenAPI tooling may send literal "string" when no value is provided if start == "string": start = None - if cursor == "string": - cursor = None - items, next_cursor = _load_local_entries(start=start, cursor=cursor) - return {"items": items, "next_cursor": next_cursor} + if end == "string": + end = None + if keyword == "string": + keyword = None + items = _search_local_entries(start=start, end=end, keyword=keyword) + return {"items": items} def _cli() -> None: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index dfcdb23..90bee9c 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -12,15 +12,15 @@ def test_limitless_route(monkeypatch, tmp_path: Path): import digital_persona.mcp_plugins.limitless as limitless monkeypatch.setattr( limitless, - "_load_local_entries", - lambda start=None, cursor=None: ([{"id": "1", "content": "hi"}], None), + "_search_local_entries", + lambda start=None, end=None, keyword=None: [{"id": "1", "content": "hi"}], ) from digital_persona import mcp_server importlib.reload(mcp_server) app = mcp_server.create_app() client = TestClient(app) - resp = client.post("/limitless/lifelogs?api_key=x", json={}) + resp = client.post("/limitless/lifelogs", json={}) assert resp.status_code == 200 assert resp.json()["items"][0]["content"] == "hi" @@ -32,12 +32,13 @@ def test_limitless_ignores_string_params(monkeypatch, tmp_path: Path): import digital_persona.mcp_plugins.limitless as limitless - def fake_load(start=None, cursor=None): + def fake_load(start=None, end=None, keyword=None): assert start is None - assert cursor is None - return [{"id": "1"}], None + assert end is None + assert keyword is None + return [{"id": "1"}] - monkeypatch.setattr(limitless, "_load_local_entries", fake_load) + monkeypatch.setattr(limitless, "_search_local_entries", fake_load) from digital_persona import mcp_server importlib.reload(mcp_server) @@ -45,7 +46,7 @@ def fake_load(start=None, cursor=None): client = TestClient(app) resp = client.post( - "/limitless/lifelogs?api_key=x", json={"start": "string", "cursor": "string"} + "/limitless/lifelogs", json={"start": "string", "end": "string", "keyword": "string"} ) assert resp.status_code == 200 assert resp.json()["items"][0]["id"] == "1" @@ -62,14 +63,14 @@ def test_limitless_http_error(monkeypatch, tmp_path: Path): def fake_load(**_): raise HTTPException(status_code=400, detail="bad") - monkeypatch.setattr(limitless, "_load_local_entries", fake_load) + monkeypatch.setattr(limitless, "_search_local_entries", fake_load) from digital_persona import mcp_server importlib.reload(mcp_server) app = mcp_server.create_app() client = TestClient(app) - resp = client.post("/limitless/lifelogs?api_key=x", json={}) + resp = client.post("/limitless/lifelogs", json={}) assert resp.status_code == 400 From 30287b0ede99725a4ce17df6adb3afe7fa87d78d Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 17:53:58 -0600 Subject: [PATCH 09/16] Search processed directory --- README.md | 2 +- src/digital_persona/mcp_plugins/limitless.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7d47b50..db301d2 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, and `keyword` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/input`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, and `keyword` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. ### Sample Data diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index a680675..be42d93 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -17,7 +17,7 @@ from digital_persona import config as dp_config dp_config.load_env() -from digital_persona.ingest import INPUT_DIR, _persona_dir +from digital_persona.ingest import INPUT_DIR, PROCESSED_DIR, _persona_dir FERNET = get_fernet(_persona_dir()) @@ -128,9 +128,12 @@ def _search_local_entries( keyword: str | None = None, limit: int = 100, ) -> list[dict]: - """Return stored Limitless entries from ``INPUT_DIR``.""" + """Return stored Limitless entries from the persona directory.""" - files = sorted(INPUT_DIR.glob("limitless-*.json")) + files = sorted( + list(PROCESSED_DIR.glob("limitless-*.json")) + + list(INPUT_DIR.glob("limitless-*.json")) + ) start_dt = None end_dt = None if start: From 70902f6fb98eeb3b87d87f13dbb6b0b0adbb7d0d Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:21:36 -0600 Subject: [PATCH 10/16] Add speaker name search --- README.md | 2 +- src/digital_persona/mcp_plugins/limitless.py | 27 +++++++++++++++++++- tests/test_mcp_server.py | 10 +++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index db301d2..8208bd6 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, and `keyword` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. ### Sample Data diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index be42d93..10e0312 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -58,6 +58,17 @@ class LifelogParams(BaseModel): description="Filter entries containing this text", examples=["meeting"], ) + speaker_name: str | None = Field( + default=None, + alias="speakerName", + description="Filter entries attributed to this speaker", + examples=["Alice"], + ) + + model_config = { + "populate_by_name": True, + "extra": "ignore", + } def setup(app: FastAPI) -> None: """Attach background ingest task to *app* startup.""" @@ -126,6 +137,7 @@ def _search_local_entries( *, start: str | None = None, end: str | None = None, keyword: str | None = None, + speaker_name: str | None = None, limit: int = 100, ) -> list[dict]: """Return stored Limitless entries from the persona directory.""" @@ -167,6 +179,14 @@ def _search_local_entries( text = json.dumps(obj, ensure_ascii=False).lower() if keyword.lower() not in text: continue + if speaker_name: + name = ( + obj.get("speakerName") + or obj.get("speaker") + or obj.get("metadata", {}).get("speakerName") + ) + if not name or speaker_name.lower() not in str(name).lower(): + continue items.append(obj) if len(items) >= limit: break @@ -227,6 +247,7 @@ async def api_lifelogs( start = params.start if params else None end = params.end if params else None keyword = params.keyword if params else None + speaker_name = params.speaker_name if params else None # OpenAPI tooling may send literal "string" when no value is provided if start == "string": start = None @@ -234,7 +255,11 @@ async def api_lifelogs( end = None if keyword == "string": keyword = None - items = _search_local_entries(start=start, end=end, keyword=keyword) + if speaker_name == "string": + speaker_name = None + items = _search_local_entries( + start=start, end=end, keyword=keyword, speaker_name=speaker_name + ) return {"items": items} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 90bee9c..4854e88 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -13,7 +13,9 @@ def test_limitless_route(monkeypatch, tmp_path: Path): monkeypatch.setattr( limitless, "_search_local_entries", - lambda start=None, end=None, keyword=None: [{"id": "1", "content": "hi"}], + lambda start=None, end=None, keyword=None, speaker_name=None: [ + {"id": "1", "content": "hi"} + ], ) from digital_persona import mcp_server @@ -32,10 +34,11 @@ def test_limitless_ignores_string_params(monkeypatch, tmp_path: Path): import digital_persona.mcp_plugins.limitless as limitless - def fake_load(start=None, end=None, keyword=None): + def fake_load(start=None, end=None, keyword=None, speaker_name=None): assert start is None assert end is None assert keyword is None + assert speaker_name is None return [{"id": "1"}] monkeypatch.setattr(limitless, "_search_local_entries", fake_load) @@ -46,7 +49,8 @@ def fake_load(start=None, end=None, keyword=None): client = TestClient(app) resp = client.post( - "/limitless/lifelogs", json={"start": "string", "end": "string", "keyword": "string"} + "/limitless/lifelogs", + json={"start": "string", "end": "string", "keyword": "string", "speakerName": "string"}, ) assert resp.status_code == 200 assert resp.json()["items"][0]["id"] == "1" From 26f3b9e6e07efcf2d982de8e51d357723230e168 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:41:04 -0600 Subject: [PATCH 11/16] Fix speakerName filtering --- src/digital_persona/mcp_plugins/limitless.py | 43 ++++++++++++++++---- tests/test_limitless.py | 34 +++++++++++++++- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index 10e0312..c52802f 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -70,6 +70,7 @@ class LifelogParams(BaseModel): "extra": "ignore", } + def setup(app: FastAPI) -> None: """Attach background ingest task to *app* startup.""" @@ -95,7 +96,9 @@ def _save_state(state: dict) -> None: STATE_FILE.write_text(json.dumps(state)) -def _fetch_entries(*, start: str | None = None, cursor: str | None = None, api_key: str = API_KEY) -> tuple[list[dict], str | None]: +def _fetch_entries( + *, start: str | None = None, cursor: str | None = None, api_key: str = API_KEY +) -> tuple[list[dict], str | None]: """Return lifelog entries and the next cursor.""" headers = {"X-API-Key": api_key} @@ -128,13 +131,33 @@ def _save_entry(entry: dict) -> None: save_json_encrypted(obj, out, FERNET) logger.info("Saved %s", out.name) + def _get_entry_filename(entry_id: str) -> os.PathLike: """Construct the file path for a given entry ID.""" return INPUT_DIR / f"limitless-{entry_id}.json" +def _contains_speaker(obj: object, speaker_name: str) -> bool: + """Return True if *obj* or nested values contain the speaker name.""" + if isinstance(obj, dict): + for key, value in obj.items(): + if key in {"speakerName", "speaker"}: + if value and speaker_name.lower() in str(value).lower(): + return True + if key == "metadata" and isinstance(value, dict): + val = value.get("speakerName") or value.get("speaker") + if val and speaker_name.lower() in str(val).lower(): + return True + if _contains_speaker(value, speaker_name): + return True + elif isinstance(obj, list): + return any(_contains_speaker(v, speaker_name) for v in obj) + return False + + def _search_local_entries( - *, start: str | None = None, + *, + start: str | None = None, end: str | None = None, keyword: str | None = None, speaker_name: str | None = None, @@ -180,12 +203,10 @@ def _search_local_entries( if keyword.lower() not in text: continue if speaker_name: - name = ( - obj.get("speakerName") - or obj.get("speaker") - or obj.get("metadata", {}).get("speakerName") - ) - if not name or speaker_name.lower() not in str(name).lower(): + try: + if not _contains_speaker(obj, speaker_name): + continue + except Exception: continue items.append(obj) if len(items) >= limit: @@ -203,7 +224,11 @@ def run_once() -> None: last_id = None if not cursor and not start: - start = (datetime.now(UTC) - timedelta(days=LOOKBACK_DAYS)).isoformat().replace("+00:00", "Z") + start = ( + (datetime.now(UTC) - timedelta(days=LOOKBACK_DAYS)) + .isoformat() + .replace("+00:00", "Z") + ) try: entries, next_cursor = _fetch_entries(start=start, cursor=cursor) diff --git a/tests/test_limitless.py b/tests/test_limitless.py index 481ad78..90746c8 100644 --- a/tests/test_limitless.py +++ b/tests/test_limitless.py @@ -11,6 +11,7 @@ def setup_limitless(monkeypatch, tmp_path: Path): monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) monkeypatch.setenv("LIMITLESS_API_KEY", "test-key") import digital_persona.mcp_plugins.limitless as limitless + limitless = importlib.reload(limitless) return limitless @@ -41,6 +42,7 @@ def test_requires_api_key(monkeypatch, tmp_path): monkeypatch.delenv("LIMITLESS_API_KEY", raising=False) with pytest.raises(RuntimeError): import importlib as _imp + _imp.reload(_imp.import_module("digital_persona.mcp_plugins.limitless")) @@ -58,7 +60,9 @@ def fake_fetch(*, start=None, cursor=None): limitless.run_once() assert captured["start"] is not None - delta = datetime.now(UTC) - datetime.fromisoformat(captured["start"].replace("Z", "+00:00")) + delta = datetime.now(UTC) - datetime.fromisoformat( + captured["start"].replace("Z", "+00:00") + ) assert 0 <= delta.days <= 2 @@ -66,7 +70,9 @@ def test_missing_file_triggers_redownload(monkeypatch, tmp_path): limitless = setup_limitless(monkeypatch, tmp_path) state_file = tmp_path / "limitless_state.json" - state_file.write_text(json.dumps({"last_id": "99", "start": "2025-07-01T00:00:00Z"})) + state_file.write_text( + json.dumps({"last_id": "99", "start": "2025-07-01T00:00:00Z"}) + ) captured = {} def fake_fetch(*, start=None, cursor=None): @@ -80,3 +86,27 @@ def fake_fetch(*, start=None, cursor=None): assert captured["start"] is not None assert captured["cursor"] is None + +def test_search_local_entries_by_speaker(monkeypatch, tmp_path): + monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) + monkeypatch.setenv("LIMITLESS_API_KEY", "x") + monkeypatch.setenv("PLAINTEXT_MEMORIES", "1") + import importlib + import digital_persona.mcp_plugins.limitless as limitless + + limitless = importlib.reload(limitless) + + entry1 = {"id": "1", "contents": [{"speakerName": "You", "content": "hi"}]} + entry2 = {"id": "2", "contents": [{"speakerName": "Alice", "content": "yo"}]} + + limitless.INPUT_DIR.mkdir(parents=True, exist_ok=True) + save_json_encrypted = limitless.save_json_encrypted + fernet = limitless.FERNET + save_json_encrypted(entry1, limitless.INPUT_DIR / "limitless-1.json", fernet) + save_json_encrypted(entry2, limitless.INPUT_DIR / "limitless-2.json", fernet) + + items = limitless._search_local_entries(speaker_name="You") + assert len(items) == 1 and items[0]["id"] == "1" + + items = limitless._search_local_entries(speaker_name="Alice") + assert len(items) == 1 and items[0]["id"] == "2" From fc9ed54f67392c846cf0c8f856bd78dfb7899d98 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sun, 20 Jul 2025 11:07:19 -0600 Subject: [PATCH 12/16] Serve ai-plugin manifest --- README.md | 2 +- src/digital_persona/mcp_server.py | 22 ++++++++++++++++++++-- tests/test_mcp_server.py | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8208bd6..3852aeb 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Tools like Open WebUI can read plugin info from `/.well-known/ai-plugin.json`. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. ### Sample Data diff --git a/src/digital_persona/mcp_server.py b/src/digital_persona/mcp_server.py index 7010bff..f0c185b 100644 --- a/src/digital_persona/mcp_server.py +++ b/src/digital_persona/mcp_server.py @@ -1,7 +1,7 @@ import os import importlib import logging -from fastapi import FastAPI +from fastapi import FastAPI, Request DEFAULT_PLUGINS = ["digital_persona.mcp_plugins.limitless"] @@ -19,12 +19,30 @@ def create_app(plugin_names: list[str] | None = None) -> FastAPI: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") # info.title becomes the plugin ID in some UIs, so avoid spaces - app = FastAPI(title="mcp_server") + app = FastAPI(title="limitless_mcp") @app.get("/", include_in_schema=False) def root() -> dict: """Basic health check message.""" return {"message": "MCP server running", "docs": "/docs"} + + @app.get("/.well-known/ai-plugin.json", include_in_schema=False) + def ai_plugin(request: Request) -> dict: + """Plugin manifest used by Open WebUI and similar tools.""" + base = str(request.base_url).rstrip("/") + return { + "schema_version": "v1", + "name_for_human": "Limitless MCP", + "name_for_model": "limitless_mcp", + "description_for_human": "Search your stored Limitless lifelogs", + "description_for_model": "Search previously ingested lifelogs via the MCP server", + "auth": {"type": "none"}, + "api": { + "type": "openapi", + "url": f"{base}{app.openapi_url}", + "is_user_authenticated": False, + }, + } for name in plugin_names: try: mod = importlib.import_module(name) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 4854e88..ef47eeb 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -90,3 +90,18 @@ def test_root_route(monkeypatch, tmp_path: Path): resp = client.get("/") assert resp.status_code == 200 assert resp.json()["message"].startswith("MCP server") + + +def test_ai_plugin(monkeypatch, tmp_path: Path): + monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) + monkeypatch.setenv("MCP_PLUGINS", "") + + from digital_persona import mcp_server + importlib.reload(mcp_server) + app = mcp_server.create_app([]) + client = TestClient(app) + + resp = client.get("/.well-known/ai-plugin.json") + assert resp.status_code == 200 + data = resp.json() + assert data["name_for_model"] == "limitless_mcp" From 43d770cbedb3e1e81ef395014e654f3e808b7f73 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sun, 20 Jul 2025 11:07:23 -0600 Subject: [PATCH 13/16] refactor MCP server and plugin --- README.md | 2 +- src/digital_persona/limitless_mcp_server.py | 42 +++++++++++++++++++++ src/digital_persona/mcp_server.py | 30 ++------------- src/digital_persona/mcp_service.py | 10 +++++ tests/test_mcp_server.py | 6 +-- 5 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 src/digital_persona/limitless_mcp_server.py create mode 100644 src/digital_persona/mcp_service.py diff --git a/README.md b/README.md index 3852aeb..5b74766 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. Tools like Open WebUI can read plugin info from `/.well-known/ai-plugin.json`. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. The Limitless service exposes its plugin manifest at `/.well-known/ai-plugin.json`. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. ### Sample Data diff --git a/src/digital_persona/limitless_mcp_server.py b/src/digital_persona/limitless_mcp_server.py new file mode 100644 index 0000000..5d7521f --- /dev/null +++ b/src/digital_persona/limitless_mcp_server.py @@ -0,0 +1,42 @@ +import os +from fastapi import FastAPI, Request + +from .mcp_server import create_app +from .mcp_service import DEFAULT_PLUGINS + + +def create_limitless_app() -> FastAPI: + app = create_app(DEFAULT_PLUGINS) + app.title = "limitless_mcp" + + @app.get("/.well-known/ai-plugin.json", include_in_schema=False) + def ai_plugin(request: Request) -> dict: + base = str(request.base_url).rstrip("/") + return { + "schema_version": "v1", + "name_for_human": "Limitless MCP", + "name_for_model": "limitless_mcp", + "description_for_human": "Search your stored Limitless lifelogs", + "description_for_model": "Search previously ingested lifelogs via the MCP server", + "auth": {"type": "none"}, + "api": { + "type": "openapi", + "url": f"{base}{app.openapi_url}", + "is_user_authenticated": False, + }, + } + + return app + + +def _cli() -> None: + import uvicorn + + app = create_limitless_app() + port = int(os.getenv("MCP_PORT", "8900")) + uvicorn.run(app, host="0.0.0.0", port=port) + + +if __name__ == "__main__": + _cli() + diff --git a/src/digital_persona/mcp_server.py b/src/digital_persona/mcp_server.py index f0c185b..f6e071c 100644 --- a/src/digital_persona/mcp_server.py +++ b/src/digital_persona/mcp_server.py @@ -1,48 +1,26 @@ import os import importlib import logging -from fastapi import FastAPI, Request +from fastapi import FastAPI -DEFAULT_PLUGINS = ["digital_persona.mcp_plugins.limitless"] +from .mcp_service import get_plugin_names def create_app(plugin_names: list[str] | None = None) -> FastAPI: if plugin_names is None: - env = os.getenv("MCP_PLUGINS") - if env: - plugin_names = [p.strip() for p in env.split(",") if p.strip()] - else: - plugin_names = DEFAULT_PLUGINS + plugin_names = get_plugin_names() logger = logging.getLogger(__name__) if not logger.handlers: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") - # info.title becomes the plugin ID in some UIs, so avoid spaces - app = FastAPI(title="limitless_mcp") + app = FastAPI(title="MCP Server") @app.get("/", include_in_schema=False) def root() -> dict: """Basic health check message.""" return {"message": "MCP server running", "docs": "/docs"} - @app.get("/.well-known/ai-plugin.json", include_in_schema=False) - def ai_plugin(request: Request) -> dict: - """Plugin manifest used by Open WebUI and similar tools.""" - base = str(request.base_url).rstrip("/") - return { - "schema_version": "v1", - "name_for_human": "Limitless MCP", - "name_for_model": "limitless_mcp", - "description_for_human": "Search your stored Limitless lifelogs", - "description_for_model": "Search previously ingested lifelogs via the MCP server", - "auth": {"type": "none"}, - "api": { - "type": "openapi", - "url": f"{base}{app.openapi_url}", - "is_user_authenticated": False, - }, - } for name in plugin_names: try: mod = importlib.import_module(name) diff --git a/src/digital_persona/mcp_service.py b/src/digital_persona/mcp_service.py new file mode 100644 index 0000000..ee94292 --- /dev/null +++ b/src/digital_persona/mcp_service.py @@ -0,0 +1,10 @@ +import os + +DEFAULT_PLUGINS = ["digital_persona.mcp_plugins.limitless"] + +def get_plugin_names() -> list[str]: + env = os.getenv("MCP_PLUGINS") + if env: + return [p.strip() for p in env.split(",") if p.strip()] + return DEFAULT_PLUGINS.copy() + diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index ef47eeb..d345185 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -96,9 +96,9 @@ def test_ai_plugin(monkeypatch, tmp_path: Path): monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) monkeypatch.setenv("MCP_PLUGINS", "") - from digital_persona import mcp_server - importlib.reload(mcp_server) - app = mcp_server.create_app([]) + from digital_persona import limitless_mcp_server + importlib.reload(limitless_mcp_server) + app = limitless_mcp_server.create_limitless_app() client = TestClient(app) resp = client.get("/.well-known/ai-plugin.json") From 4e3412b251d0ba07cc516772ff4b56606e8ce501 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sun, 20 Jul 2025 11:29:01 -0600 Subject: [PATCH 14/16] Serve plugin manifest from Limitless plugin --- README.md | 2 +- src/digital_persona/limitless_mcp_server.py | 19 +------------------ src/digital_persona/mcp_plugins/limitless.py | 20 +++++++++++++++++++- tests/test_mcp_server.py | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5b74766..7be18e6 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. The Limitless service exposes its plugin manifest at `/.well-known/ai-plugin.json`. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. When the Limitless plugin is enabled the server also serves its plugin manifest from `/.well-known/ai-plugin.json`. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. ### Sample Data diff --git a/src/digital_persona/limitless_mcp_server.py b/src/digital_persona/limitless_mcp_server.py index 5d7521f..cb6bea1 100644 --- a/src/digital_persona/limitless_mcp_server.py +++ b/src/digital_persona/limitless_mcp_server.py @@ -1,5 +1,5 @@ import os -from fastapi import FastAPI, Request +from fastapi import FastAPI from .mcp_server import create_app from .mcp_service import DEFAULT_PLUGINS @@ -9,23 +9,6 @@ def create_limitless_app() -> FastAPI: app = create_app(DEFAULT_PLUGINS) app.title = "limitless_mcp" - @app.get("/.well-known/ai-plugin.json", include_in_schema=False) - def ai_plugin(request: Request) -> dict: - base = str(request.base_url).rstrip("/") - return { - "schema_version": "v1", - "name_for_human": "Limitless MCP", - "name_for_model": "limitless_mcp", - "description_for_human": "Search your stored Limitless lifelogs", - "description_for_model": "Search previously ingested lifelogs via the MCP server", - "auth": {"type": "none"}, - "api": { - "type": "openapi", - "url": f"{base}{app.openapi_url}", - "is_user_authenticated": False, - }, - } - return app diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index c52802f..d6deba8 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta, UTC import asyncio import httpx -from fastapi import APIRouter, FastAPI, Security +from fastapi import APIRouter, FastAPI, Security, Request from fastapi.security import APIKeyQuery from pydantic import BaseModel, Field from digital_persona.utils.filename import sanitize_filename @@ -82,6 +82,24 @@ async def _loop() -> None: app.add_event_handler("startup", lambda: asyncio.create_task(_loop())) + @app.get("/.well-known/ai-plugin.json", include_in_schema=False) + def ai_plugin(request: Request) -> dict: + """Return Open WebUI plugin manifest.""" + base = str(request.base_url).rstrip("/") + return { + "schema_version": "v1", + "name_for_human": "Limitless MCP", + "name_for_model": "limitless_mcp", + "description_for_human": "Search your stored Limitless lifelogs", + "description_for_model": "Search previously ingested lifelogs via the MCP server", + "auth": {"type": "none"}, + "api": { + "type": "openapi", + "url": f"{base}{app.openapi_url}", + "is_user_authenticated": False, + }, + } + def _load_state() -> dict: if STATE_FILE.exists(): diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d345185..5cf4608 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -105,3 +105,18 @@ def test_ai_plugin(monkeypatch, tmp_path: Path): assert resp.status_code == 200 data = resp.json() assert data["name_for_model"] == "limitless_mcp" + + +def test_ai_plugin_via_create_app(monkeypatch, tmp_path: Path): + monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) + monkeypatch.setenv("LIMITLESS_API_KEY", "x") + monkeypatch.setenv("MCP_PLUGINS", "digital_persona.mcp_plugins.limitless") + + from digital_persona import mcp_server + importlib.reload(mcp_server) + app = mcp_server.create_app() + client = TestClient(app) + + resp = client.get("/.well-known/ai-plugin.json") + assert resp.status_code == 200 + assert resp.json()["name_for_model"] == "limitless_mcp" From 2f9d4de45ed29679d9d5348236c04bd65632bb75 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sun, 20 Jul 2025 11:56:13 -0600 Subject: [PATCH 15/16] Serve plugin manifest with explicit JSON --- src/digital_persona/mcp_plugins/limitless.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/digital_persona/mcp_plugins/limitless.py b/src/digital_persona/mcp_plugins/limitless.py index d6deba8..1afb5ba 100644 --- a/src/digital_persona/mcp_plugins/limitless.py +++ b/src/digital_persona/mcp_plugins/limitless.py @@ -6,6 +6,7 @@ import httpx from fastapi import APIRouter, FastAPI, Security, Request from fastapi.security import APIKeyQuery +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from digital_persona.utils.filename import sanitize_filename from digital_persona.secure_storage import ( @@ -83,10 +84,10 @@ async def _loop() -> None: app.add_event_handler("startup", lambda: asyncio.create_task(_loop())) @app.get("/.well-known/ai-plugin.json", include_in_schema=False) - def ai_plugin(request: Request) -> dict: + def ai_plugin(request: Request) -> JSONResponse: """Return Open WebUI plugin manifest.""" base = str(request.base_url).rstrip("/") - return { + manifest = { "schema_version": "v1", "name_for_human": "Limitless MCP", "name_for_model": "limitless_mcp", @@ -98,7 +99,11 @@ def ai_plugin(request: Request) -> dict: "url": f"{base}{app.openapi_url}", "is_user_authenticated": False, }, + "logo_url": f"{base}/logo.png", + "contact_email": "support@example.com", + "legal_info_url": "https://example.com/legal", } + return JSONResponse(content=manifest) def _load_state() -> dict: From 684515108282e1ed70cd7ee793fcbd4314d11c60 Mon Sep 17 00:00:00 2001 From: Eric Hackathorn <62408375+Hackshaven@users.noreply.github.com> Date: Sun, 20 Jul 2025 12:59:08 -0600 Subject: [PATCH 16/16] Allow CORS for Open WebUI --- README.md | 2 +- src/digital_persona/mcp_server.py | 7 +++++++ tests/test_mcp_server.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7be18e6..23ef0f9 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Environment variables: - The object also stores a relative `source` path to the processed original file so you can reference images or audio later. - Non-text media should be ingested first so a text summary is available. - Completed memories are moved to `PERSONA_DIR/archive` after `/complete_interview` so they won't be processed twice. - - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. When the Limitless plugin is enabled the server also serves its plugin manifest from `/.well-known/ai-plugin.json`. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. + - The MCP server runs on `http://localhost:8900`. Visit `/` for a quick status check, `/docs` for the UI, or `/openapi.json` for the spec. CORS is enabled so other tools can fetch `/openapi.json`. When the Limitless plugin is enabled the server also serves its plugin manifest from `/.well-known/ai-plugin.json`. Call `POST /limitless/lifelogs` with a JSON body containing optional `start`, `end`, `keyword`, and `speakerName` fields to search previously fetched Limitless lifelogs stored in `PERSONA_DIR/processed`. You may append `?api_key=YOUR_KEY` but it is only required when the server fetches data from Limitless directly. ### Sample Data diff --git a/src/digital_persona/mcp_server.py b/src/digital_persona/mcp_server.py index f6e071c..57c769c 100644 --- a/src/digital_persona/mcp_server.py +++ b/src/digital_persona/mcp_server.py @@ -2,6 +2,7 @@ import importlib import logging from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from .mcp_service import get_plugin_names @@ -15,6 +16,12 @@ def create_app(plugin_names: list[str] | None = None) -> FastAPI: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") app = FastAPI(title="MCP Server") + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) @app.get("/", include_in_schema=False) def root() -> dict: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 5cf4608..d091032 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -120,3 +120,23 @@ def test_ai_plugin_via_create_app(monkeypatch, tmp_path: Path): resp = client.get("/.well-known/ai-plugin.json") assert resp.status_code == 200 assert resp.json()["name_for_model"] == "limitless_mcp" + + +def test_cors_options(monkeypatch, tmp_path: Path): + monkeypatch.setenv("PERSONA_DIR", str(tmp_path)) + monkeypatch.setenv("MCP_PLUGINS", "") + + from digital_persona import mcp_server + importlib.reload(mcp_server) + app = mcp_server.create_app([]) + client = TestClient(app) + + resp = client.options( + "/openapi.json", + headers={ + "Origin": "http://example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code in {200, 204} + assert resp.headers.get("access-control-allow-origin") == "*"