8000 feat(mcp): let admins see and change where files are kept by adeelahmad · Pull Request #151 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ Todo:
- [x] Attachments on notes in the file store: BlockSuite's blob source keeps images and files there (note_file)
- [ ] Moving uploads and recordings' media to the file store (pipelines need local copies: a cache)
- [ ] A downloadable storage key (the server secret; opens every namespace that isn't a passkey vault)
- [ ] File storage over MCP (admins)
- [x] File storage over MCP (admins): file_storage, set_file_storage (checked before saving)

## 2026-10-04 · The graph, end to end

Expand Down
5 changes: 4 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ front of the web app also needs `TRUST_PROXY_HEADERS=true` ([Authentication](aut
| `find_notes` | [notes](notes.md) by words in their title, summary or text, or by where they're filed (PARA), with links |
| `read_note` | a note's Markdown with its links and backlinks, by id or as the page of a thing (`recording:12`) |
| `write_note` | write a note or a thing's page, marked as written by an assistant. Needs a write-scope token and editor access |
| `file_storage` | where Lens keeps its own files (notes' images and attachments) and the connections it could use; `check` tests it. Admins only |
| `set_file_storage` | keep new files on this machine or a storage connection (folder, rclone crypt); checked before it's saved. Admins only, with a write-scope token |
| `propose_graph_change` | ask for two entities to be merged or linked; it waits in Proposed changes unless `apply` makes it at once (it can be undone). Needs a write-scope token and editor access |

Results are JSON (as `structuredContent`, and the same as text). Links open the recording's page in the web app at
Expand All @@ -67,7 +69,8 @@ person's to read answers "not found", as the API does. `search` and `fetch` foll
expect (`results` with `id`, `title`, `url`; `fetch` by that `id`).

Every tool reads, except `suggest_topic`, which adds suggestions people accept or dismiss, `propose_graph_change`,
which records an undoable change (proposed by default), and `write_note`, which adds a note.
which records an undoable change (proposed by default), `write_note`, which adds a note, and `set_file_storage`,
which changes where new files are kept (admins).
Importing, editing and curating stay in the web app and the API.

## Protocol
Expand Down
3 changes: 2 additions & 1 deletion docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ from wherever they were written, and new ones go to the new place.
`POST /api/v1/settings/files/test` writes a small file there, reads it back and removes it: what is saved, or a
`{store, connection, folder, crypt}` given in the body, to check before saving. The assistant (for admins) has
`file_storage`, which says where files go and which connections could take them, and checks it with `check`; it
changes it with `change_settings files`, which always waits for approval.
changes it with `change_settings files`, which always waits for approval. Agents connected over [MCP](mcp.md) have
the same as `file_storage` and `set_file_storage` (admins only; the change is checked before it's saved).
71 changes: 70 additions & 1 deletion fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from app.api.v1.routes import recordings as recording_routes
from app.api.v1.routes import search as search_routes
from app.api.v1.routes import topics as topic_routes
from app.domain import analyze, graph_history, library, notebook, rdf, render, store
from app.domain import analyze, auth, blobs, feeds, graph_history, library, notebook, rdf, render, settings, sources, store
from app.domain import entities as ents
from app.domain import speakers as spk
from app.domain import topics as topicmod
Expand Down Expand Up @@ -1262,3 +1262,72 @@ def write_note(
sid = ctx.acl.namespace(namespace, "editor")
pid = notebook.create(ctx.db, sid, ctx.user.id, title, body, summary, None, place, parent_id, about, author="assistant")
return {"note_id": pid, "url": _note_url(ctx, pid)}


# ---------- where files are kept (admins) ----------
def _admin(ctx: Context) -> None:
if not ctx.user.admin:
raise ToolError("only admins see or change where Lens keeps its files")


@tool(
"file_storage",
"Where files are kept",
"Where Lens keeps the files it makes its own (notes' images and attachments): this machine, or a storage "
"connection (S3, Google Drive, Dropbox, OneDrive, SFTP, SMB, WebDAV) under a folder, optionally through rclone "
"crypt; and the connections it could use. check writes, reads back and removes a small file there. Admins only.",
Arg("check", "boolean", "also check that it works", default=False),
)
def file_storage(ctx: Context, check: bool = False) -> dict[str, Any]:
_admin(ctx)
f = ctx.cfg.get("files") or {}
out: dict[str, Any] = {
"store": f.get("store", "local"),
"connection": f.get("connection"),
"folder": f.get("folder"),
"crypt": bool(f.get("crypt")),
"connections": [
{"id": c["id"], "name": c["name"], "type": c["type"]} for c in sources.list_sources(ctx.db) if not feeds.handles(c)
],
"settings": f"{ctx.web}/settings/storage",
}
if check:
out["check"] = blobs.test(ctx.db, ctx.cfg)
return out


@tool(
"set_file_storage",
"Change where files are kept",
"Keep new files on this machine (store local) or on a storage connection (store connection, with connection, "
"folder and crypt). It's checked first and saved only if a small file can be written, read back and removed "
"there. Files already kept stay where they are. New connections are added in the web app (Settings → Sources). "
"Admins only, with a token that has the write scope.",
Arg("store", "string", "local or connection", required=True, enum=("local", "connection")),
Arg("connection", "integer", "the storage connection's id (from file_storage)"),
Arg("folder", "string", "where on it: for S3 the bucket and a path, for a folder here its full path", max_length=200),
Arg("crypt", "boolean", "also wrap it in rclone crypt", default=False),
writes=True,
)
def set_file_storage(
ctx: Context, store: str, connection: int | None = None, folder: str | None = None, crypt: bool = False
) -> dict[str, Any]:
_admin(ctx)
if not ctx.user.can_write:
raise ToolError("this token is read-only: ask for a token with the write scope to change where files are kept")
if store == "connection":
if not connection:
raise ToolError("give the connection's id (file_storage lists them)")
blobs.check_connection(ctx.db, connection)
where = {"store": "connection", "connection": int(connection), "folder": blobs._folder(folder or "lens"), "crypt": bool(crypt)}
else:
where = {"store": "local"}
checked = blobs.test(ctx.db, ctx.cfg, where)
if not checked["ok"]:
raise ToolError(f"that didn't work, so nothing changed: {checked['error']}")
changes = {"store": store} | (
{"connection": where["connection"], "folder": where["folder"], "crypt": where["crypt"]} if store == "connection" else {}
)
settings.save(ctx.db, ctx.request.app.state.archive.base, "files", changes, ctx.user.email)
auth.audit(ctx.db, ctx.user.as_audit(), "settings.save", "files", sorted(changes) + ["mcp"])
return {"saved": changes, "check": checked}
22 changes: 21 additions & 1 deletion fastapi_backend/tests/api/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ def test_clients_are_sent_to_sign_in(client, app, env):
assert [t["name"] for t in tools] == list(mcp_tools.TOOLS)
assert all(t["inputSchema"]["type"] == "object" for t in tools)
# every tool only reads, except suggesting a topic and asking for a graph change (which need the write scope)
assert [t["name"] for t in tools if not t["annotations"]["readOnlyHint"]] == ["suggest_topic", "propose_graph_change", "write_note"]
assert [t["name"] for t in tools if not t["annotations"]["readOnlyHint"]] == [
"suggest_topic",
"propose_graph_change",
"write_note",
"set_file_storage",
]
assert next(t for t in tools if t["name"] == "search")["inputSchema"]["required"] == ["query"]
# API tokens and sessions work too
assert rpc(client, env["hv"], "ping")["result"] == {}
Expand Down Expand Up @@ -424,3 +429,18 @@ def test_note_tools(client, db, env):
p = notebook.get(db, made["note_id"])
assert (p["author"], p["place"], p["parent"]) == ("assistant", "resource", plan)
assert tool_error(client, he, "write_note", namespace="calls", title="x", body="y")


def test_file_storage_tools(client, db, env, folder):
assert "only admins" in tool_error(client, env["h"], "file_storage")
make_user(db, "root@x.io", "root password 1", admin=True)
ha = login(client, "root@x.io", "root password 1")
got = tool(client, ha, "file_storage", check=True)
assert (got["store"], got["connection"], got["connections"], got["check"]["ok"]) == ("local", None, [], True)
assert got["settings"].endswith("/settings/storage")
assert "connection's id" in tool_error(client, ha, "set_file_storage", store="connection")
assert tool_error(client, ha, "set_file_storage", store="connection", connection=99)
out = tool(client, ha, "set_file_storage", store="local")
assert out["saved"] == {"store": "local"} and out["check"]["ok"]
assert client.app.state.archive.current()["files"]["store"] == "local"
assert "only admins" in tool_error(client, env["h"], "set_file_storage", store="local")
Loading
0