From 4e1d17d393d53f951ba126a35210a2ab32cd2698 Mon Sep 17 00:00:00 2001 From: Cl0ver Date: Fri, 25 Sep 2026 14:00:19 -0400 Subject: [PATCH 1/4] feat(api): serve a public demo with question limits and MCP over HTTP chatlore serve --public is for a server on the internet behind a proxy. Every question that reaches the language model is paid for with the host's key, so they are limited: 10 an hour for each visitor and 300 a day for everyone, by default, with the visitor told why and how to install ChatLore instead. Questions that find no passages never reach the model and are not counted. Visitors are told apart by the address the proxy forwards. /health says the server is public, and the web interface then says it is a demo. chatlore serve now also serves the MCP server at /mcp over streamable HTTP, stateless and with plain JSON replies, for assistants that connect to a URL; ChatGPT only connects that way. A private server keeps the SDK's protection against DNS rebinding, so /mcp only answers requests addressed to this machine; a public one accepts any host name. --- src/chatlore/api.py | 109 +++++++++++++++++++++-- src/chatlore/chat.py | 11 +++ src/chatlore/cli.py | 54 ++++++++++-- src/chatlore/web/app.js | 9 ++ src/chatlore/web/index.html | 4 + src/chatlore/web/style.css | 3 + tests/test_public.py | 167 ++++++++++++++++++++++++++++++++++++ 7 files changed, 341 insertions(+), 16 deletions(-) create mode 100644 tests/test_public.py diff --git a/src/chatlore/api.py b/src/chatlore/api.py index d1a9d50..378c93e 100644 --- a/src/chatlore/api.py +++ b/src/chatlore/api.py @@ -6,28 +6,39 @@ is written, then which sources it cited. The web interface at / is built on these endpoints. +The MCP server is served at /mcp too, over streamable HTTP, for assistants +that connect to a URL instead of starting ``chatlore mcp``. + Each request opens the store and closes it again, so requests never share a database connection across threads. The server listens on 127.0.0.1 unless told -otherwise, because the library is private. +otherwise, because the library is private. A public server, such as the hosted +demo, limits how many questions reach the language model, since each one is paid +for with the host's key. """ from __future__ import annotations -from collections.abc import Iterator +import threading +import time +from collections import deque +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import asynccontextmanager from pathlib import Path from typing import Annotated, Any -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Request from fastapi.sse import EventSourceResponse, ServerSentEvent from fastapi.staticfiles import StaticFiles +from mcp.server.transport_security import TransportSecuritySettings from pydantic import BaseModel, Field from starlette.responses import Response from starlette.types import Scope from chatlore import __version__ -from chatlore.chat import MAX_SOURCES, Context, Source, answer, cited, retrieve +from chatlore.chat import MAX_SOURCES, ChatLimits, Context, Source, answer, cited, retrieve from chatlore.embeddings import Embedder, EmbeddingError, make_embedder, normalise from chatlore.llm import LLMError, make_llm +from chatlore.mcp_server import create_server from chatlore.paths import default_home from chatlore.search import hybrid_search from chatlore.store import EdgeType, GraphStore, Label, Node, open_store @@ -49,6 +60,54 @@ async def get_response(self, path: str, scope: Scope) -> Response: return response +_HOUR = 3600.0 +_MAX_VISITORS = 10_000 + + +class _Allowance: + """Counts questions against ``ChatLimits``. Shared by the request threads.""" + + def __init__(self, limits: ChatLimits, clock: Callable[[], float] = time.time) -> None: + self.limits = limits + self._clock = clock + self._lock = threading.Lock() + self._visitors: dict[str, deque[float]] = {} + self._day = "" + self._today = 0 + + def take(self, visitor: str) -> str | None: + """Count a question from ``visitor``, or say why it cannot be asked now.""" + now = self._clock() + day = time.strftime("%Y-%m-%d", time.gmtime(now)) + with self._lock: + if day != self._day: + self._day, self._today = day, 0 + asked = self._visitors.setdefault(visitor, deque()) + while asked and asked[0] <= now - _HOUR: + asked.popleft() + if len(asked) >= self.limits.per_visitor_hour: + return ( + f"This demo answers {self.limits.per_visitor_hour} questions an hour for " + "each visitor. Try again later, or install ChatLore to ask your own " + "conversations: pip install chatlore" + ) + if self._today >= self.limits.per_day: + return ( + "This demo has answered all the questions it can today. Try again " + "tomorrow, or install ChatLore to ask your own conversations: " + "pip install chatlore" + ) + asked.append(now) + self._today += 1 + if len(self._visitors) > _MAX_VISITORS: + self._visitors = { + key: times + for key, times in self._visitors.items() + if times and times[-1] > now - _HOUR + } + return None + + class ChatRequest(BaseModel): question: str = Field(min_length=1, max_length=4000) sources: int = Field(default=MAX_SOURCES, ge=1, le=20) @@ -90,15 +149,39 @@ def _topic(node: Node, entities: int | None = None) -> dict[str, Any]: } -def create_app(home: Path | None = None) -> FastAPI: - """The API over the library in ``home``, or the default ChatLore home.""" +def create_app( + home: Path | None = None, *, public: bool = False, limits: ChatLimits | None = None +) -> FastAPI: + """The API over the library in ``home``, or the default ChatLore home. + + A ``public`` app says so at /health, which makes the web interface show a + demo note, and accepts MCP requests for any host name, since it is meant to + be reached from the internet. Otherwise /mcp only answers requests addressed + to this machine, which keeps other websites from reaching it through the + browser. ``limits`` caps the questions sent to the language model. + """ library = home or default_home() + assistants = create_server(library) + security = TransportSecuritySettings(enable_dns_rebinding_protection=False) if public else None + # Stateless, with plain JSON replies: any worker can answer any request, and + # no tool streams its result. + mcp_app = assistants.streamable_http_app( + stateless_http=True, json_response=True, transport_security=security + ) + + @asynccontextmanager + async def lifespan(_: FastAPI) -> AsyncIterator[None]: + async with assistants.session_manager.run(): + yield + app = FastAPI( title="ChatLore", version=__version__, description="All your AI conversations, one graph, one chat.", + lifespan=lifespan, ) embedders: list[Embedder] = [] + allowance = _Allowance(limits) if limits is not None else None def store() -> GraphStore: return open_store(library) @@ -115,8 +198,8 @@ def embed(text: str, graph: GraphStore) -> list[float] | None: raise HTTPException(503, str(error)) from error @app.get("/health") - def health() -> dict[str, str]: - return {"status": "ok", "version": __version__} + def health() -> dict[str, Any]: + return {"status": "ok", "version": __version__, "public": public} @app.get("/stats") def stats() -> dict[str, int]: @@ -370,7 +453,7 @@ def graph( } @app.post("/chat", response_class=EventSourceResponse) - def chat(request: ChatRequest) -> Iterator[ServerSentEvent]: + def chat(request: ChatRequest, http: Request) -> Iterator[ServerSentEvent]: """Answer a question, streaming `sources`, then `token`s, then `done` or `error`. `sources` lists the numbered passages the answer may cite. Each `token` @@ -390,6 +473,11 @@ def chat(request: ChatRequest) -> Iterator[ServerSentEvent]: if context.empty: yield ServerSentEvent(event="done", data={"cited": [], "found": False}) return + if allowance is not None: + refusal = allowance.take(http.client.host if http.client else "unknown") + if refusal is not None: + yield ServerSentEvent(event="error", data={"message": refusal}) + return try: llm = make_llm() except LLMError as error: @@ -408,6 +496,9 @@ def chat(request: ChatRequest) -> Iterator[ServerSentEvent]: numbers = [source.number for source in cited("".join(written), context)] yield ServerSentEvent(event="done", data={"cited": numbers, "found": True}) + # POST /mcp for assistants, next to the API. + app.router.routes.extend(mcp_app.routes) + # The web interface: plain files, no build step. Mounted last, so every API # route above takes precedence over a file of the same name. app.mount("/", _WebFiles(directory=WEB, html=True), name="web") diff --git a/src/chatlore/chat.py b/src/chatlore/chat.py index a4ab3cb..52aa3c5 100644 --- a/src/chatlore/chat.py +++ b/src/chatlore/chat.py @@ -31,6 +31,17 @@ MAX_ANSWER_TOKENS = 1500 MAX_NAME_WORDS = 4 + +@dataclass(frozen=True, slots=True) +class ChatLimits: + """How many questions a public server sends to the language model.""" + + per_visitor_hour: int = 10 + """Questions one visitor, told apart by address, may ask in any hour.""" + per_day: int = 300 + """Questions all visitors together may ask in a day, counted in UTC.""" + + _WORD = re.compile(r"[\w][\w.+#'-]*") _CITATION = re.compile(r"\[(\d+)\]") _NOT_NAMES = frozenset( diff --git a/src/chatlore/cli.py b/src/chatlore/cli.py index 44f6d21..d8f99fe 100644 --- a/src/chatlore/cli.py +++ b/src/chatlore/cli.py @@ -12,7 +12,7 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Annotated +from typing import Annotated, Any import typer from dotenv import find_dotenv, load_dotenv @@ -29,7 +29,7 @@ import_archive, is_archive, ) -from chatlore.chat import MAX_SOURCES, answer, cited, retrieve +from chatlore.chat import MAX_SOURCES, ChatLimits, answer, cited, retrieve from chatlore.embeddings import EmbeddingCache, EmbeddingError, make_embedder, normalise from chatlore.extraction import ExtractionCache, entity_key from chatlore.importers import ( @@ -703,19 +703,59 @@ def serve( port: Annotated[ int, typer.Option("--port", min=1, max=65535, help="Port to listen on.") ] = 8000, + public: Annotated[ + bool, + typer.Option( + "--public", + help=( + "Serve a public demo behind a proxy: limit the questions sent to the " + "model, take visitors' addresses from the proxy, and show a demo note." + ), + ), + ] = False, + questions_per_hour: Annotated[ + int, + typer.Option( + "--questions-per-hour", + min=1, + help="With --public: questions one visitor may ask per hour.", + ), + ] = ChatLimits().per_visitor_hour, + questions_per_day: Annotated[ + int, + typer.Option( + "--questions-per-day", + min=1, + help="With --public: questions all visitors may ask per day.", + ), + ] = ChatLimits().per_day, ) -> None: - """Serve the REST API with streaming chat. Interactive docs are at /docs.""" + """Serve the web interface, the REST API, and MCP at /mcp. API docs are at /docs.""" import uvicorn # loaded here so other commands start without the web stack from chatlore.api import create_app - if host not in _LOCAL_HOSTS: + options: dict[str, Any] = {} + if public: + limits = ChatLimits(per_visitor_hour=questions_per_hour, per_day=questions_per_day) + application = create_app(public=True, limits=limits) + # The proxy in front says who the visitor is; without it every visitor + # would share the proxy's address, and its limit. + options = {"proxy_headers": True, "forwarded_allow_ips": "*"} console.print( - "[yellow]Listening beyond this machine: anyone who can reach this address " - "can read your library.[/yellow]" + f"Public demo of {display_path(default_home())}: anyone who can reach it can read " + f"this library and ask {questions_per_hour} questions an hour each, " + f"{questions_per_day} a day in all." ) + else: + application = create_app() + if host not in _LOCAL_HOSTS: + console.print( + "[yellow]Listening beyond this machine: anyone who can reach this address " + "can read your library.[/yellow]" + ) console.print(f"ChatLore API on http://{host}:{port} (docs: http://{host}:{port}/docs)") - uvicorn.run(create_app(), host=host, port=port, log_level="warning") + uvicorn.run(application, host=host, port=port, log_level="warning", **options) @app.command() diff --git a/src/chatlore/web/app.js b/src/chatlore/web/app.js index 35d639e..df323d2 100644 --- a/src/chatlore/web/app.js +++ b/src/chatlore/web/app.js @@ -128,6 +128,15 @@ api("/stats") }) .catch(() => {}); +// A public server, such as the hosted demo, says so. +api("/health") + .then((health) => { + if (!health.public) return; + $("#ask-hero p").textContent = "A demo on made-up conversations. Every answer cites the chats it came from."; + $("#demo-note").hidden = false; + }) + .catch(() => {}); + // -- conversations --------------------------------------------------------------------- async function openConversation(id, messageId) { diff --git a/src/chatlore/web/index.html b/src/chatlore/web/index.html index 5c2ef70..cb81526 100644 --- a/src/chatlore/web/index.html +++ b/src/chatlore/web/index.html @@ -52,6 +52,10 @@
Entities
–
Topics
–
+ diff --git a/src/chatlore/web/style.css b/src/chatlore/web/style.css index a43479d..7550c7a 100644 --- a/src/chatlore/web/style.css +++ b/src/chatlore/web/style.css @@ -139,6 +139,9 @@ nav button.active { background: var(--accent-soft); color: var(--accent-text); } .library dl div { display: flex; justify-content: space-between; font-size: 13px; } .library dt { color: var(--muted); } .library dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; } +.demo-note { margin: 10px 0 0; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; line-height: 1.45; color: var(--muted); } +.demo-note code { font-size: 11.5px; color: var(--text-2); } +.demo-note a { color: var(--accent-text); } main { overflow-y: auto; position: relative; } diff --git a/tests/test_public.py b/tests/test_public.py new file mode 100644 index 0000000..71b1cac --- /dev/null +++ b/tests/test_public.py @@ -0,0 +1,167 @@ +"""Tests for serving a public demo: question limits, the demo flag, and MCP over HTTP.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +from chatlore.api import _Allowance, create_app +from chatlore.chat import ChatLimits +from chatlore.cli import app as cli +from tests.fakes import FakeEmbedder, FakeLLM + +runner = CliRunner() + +NOTES = { + "Mac tools": "Parsec keeps dropping frames on the Mac. Try Moonlight with Sunshine instead.", + "Weekend": "Lisbon and Sintra make a good weekend. Take the train from Rossio to Sintra.", +} +MCP_HEADERS = {"Accept": "application/json, text/event-stream"} +LIST_TOOLS = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + + +@pytest.fixture +def home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + target = tmp_path / "home" + monkeypatch.setenv("CHATLORE_HOME", str(target)) + for module in ("cli", "api", "mcp_server"): + monkeypatch.setattr(f"chatlore.{module}.make_embedder", lambda home: FakeEmbedder()) + fake = FakeLLM() + monkeypatch.setattr("chatlore.cli.make_llm", lambda: fake) + monkeypatch.setattr("chatlore.api.make_llm", lambda: fake) + for title, text in NOTES.items(): + runner.invoke(cli, ["note", text, "--title", title]) + runner.invoke(cli, ["process"]) + runner.invoke(cli, ["extract"]) + return target + + +@pytest.fixture +def public(home: Path) -> Iterator[TestClient]: + limits = ChatLimits(per_visitor_hour=2, per_day=3) + with TestClient(create_app(home, public=True, limits=limits)) as client: + yield client + + +def ask(client: TestClient) -> list[str]: + """Ask a question and return the kinds of events streamed back.""" + response = client.post("/chat", json={"question": "Which train goes to Sintra?"}) + return [ + line.split(": ", 1)[1] for line in response.text.splitlines() if line.startswith("event") + ] + + +def test_a_visitor_gets_a_few_questions_an_hour() -> None: + now = [1_000_000.0] + allowance = _Allowance(ChatLimits(per_visitor_hour=2, per_day=100), clock=lambda: now[0]) + + first, second, third = (allowance.take("a") for _ in range(3)) + other = allowance.take("b") + now[0] += 3600 + later = allowance.take("a") + + assert first is None and second is None + assert third is not None and "2 questions an hour" in third + assert other is None + assert later is None + + +def test_all_visitors_share_a_daily_limit_that_resets_at_midnight_utc() -> None: + now = [86_400.0 * 20_000 + 3600] + allowance = _Allowance(ChatLimits(per_visitor_hour=10, per_day=2), clock=lambda: now[0]) + + answers = [allowance.take(visitor) for visitor in ("a", "b", "c")] + now[0] += 86_400 + tomorrow = allowance.take("c") + + assert answers[:2] == [None, None] + assert answers[2] is not None and "all the questions it can today" in answers[2] + assert tomorrow is None + + +def test_a_public_server_limits_questions_and_says_why(public: TestClient) -> None: + events = [ask(public) for _ in range(3)] + + assert events[0][-1] == "done" + assert events[1][-1] == "done" + assert events[2] == ["sources", "error"] + refused = public.post("/chat", json={"question": "Which train goes to Sintra?"}).text + assert "2 questions an hour for each visitor" in refused + assert "pip install chatlore" in refused + + +def test_questions_that_find_nothing_are_not_counted(tmp_path: Path) -> None: + limits = ChatLimits(per_visitor_hour=1, per_day=1) + with TestClient(create_app(tmp_path / "empty", public=True, limits=limits)) as client: + for _ in range(3): + assert ask(client) == ["sources", "done"] + + +def test_the_health_check_says_whether_the_server_is_public(public: TestClient, home: Path) -> None: + with TestClient(create_app(home)) as private: + assert private.get("/health").json()["public"] is False + assert public.get("/health").json()["public"] is True + + +def test_a_private_server_answers_questions_without_limits(home: Path) -> None: + with TestClient(create_app(home)) as private: + for _ in range(5): + response = private.post("/chat", json={"question": "Which train goes to Sintra?"}) + assert "event: done" in response.text + + +def test_mcp_is_served_over_http(public: TestClient) -> None: + listed = public.post("/mcp", json=LIST_TOOLS, headers=MCP_HEADERS).json() + called = public.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "search", "arguments": {"query": "Sintra"}}, + }, + headers=MCP_HEADERS, + ).json() + + assert [tool["name"] for tool in listed["result"]["tools"]][:2] == ["search", "ask_context"] + assert "Weekend" in called["result"]["content"][0]["text"] + + +def test_a_private_server_only_answers_mcp_requests_for_this_machine(home: Path) -> None: + with TestClient(create_app(home)) as private: + foreign = private.post( + "/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Host": "evil.example"} + ) + local = private.post( + "/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Host": "127.0.0.1:8000"} + ) + + assert foreign.status_code == 421 + assert local.status_code == 200 + assert json.loads(local.text)["result"]["tools"] + + +def test_serve_public_trusts_the_proxy_and_sets_the_limits( + home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + started: list[tuple[Any, dict[str, Any]]] = [] + monkeypatch.setattr("uvicorn.run", lambda app, **options: started.append((app, options))) + + result = runner.invoke( + cli, ["serve", "--public", "--host", "0.0.0.0", "--questions-per-hour", "4"] + ) + + app, options = started[0] + assert result.exit_code == 0 + assert options["proxy_headers"] is True + assert options["forwarded_allow_ips"] == "*" + assert "ask 4 questions an hour each, 300 a day in all" in " ".join(result.output.split()) + assert "Listening beyond this machine" not in result.output + with TestClient(app) as client: + assert client.get("/health").json()["public"] is True From c5327ff33b438ae529eb41522c7f214e3aac9a90 Mon Sep 17 00:00:00 2001 From: Cl0ver Date: Fri, 25 Sep 2026 14:00:19 -0400 Subject: [PATCH 2/4] build: add a Docker image of the hosted demo The image installs the locked dependencies with uv, loads the demo library, and downloads the embedding model while building, so a container answers as soon as it starts. It runs as user 1000, as Hugging Face Spaces and other hosts expect, and serves the public demo on $PORT or 7860. Only the files the image needs are sent to the build, so a local .env or library can never end up in it. CI builds the image, starts it, and checks the web interface, the API, and a tool call over MCP. --- .dockerignore | 8 ++++++++ .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ Dockerfile | 31 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02181d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Only what the image needs: pyproject.toml, uv.lock, README.md, LICENSE, src/. +* +!pyproject.toml +!uv.lock +!README.md +!LICENSE +!src/ +**/__pycache__ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 224940e..353359a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,3 +68,30 @@ jobs: uvx --from "$wheel" chatlore --version uvx --from "$wheel" chatlore demo --no-serve uvx --from "$wheel" chatlore --home ~/.chatlore-demo search "litestream" + + docker: + name: Docker image + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Build + run: docker build -t chatlore-demo . + + - name: Start it and check the web interface, API, and MCP + run: | + docker run -d --name demo -p 7860:7860 chatlore-demo + for attempt in $(seq 30); do curl -fs http://127.0.0.1:7860/health && break; sleep 2; done + curl -fs http://127.0.0.1:7860/health | grep -q '"public":true' + curl -fs http://127.0.0.1:7860/ | grep -q 'ChatLore' + curl -fs http://127.0.0.1:7860/stats | grep -q '"conversations":32' + curl -fs http://127.0.0.1:7860/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"litestream"}}}' \ + | grep -q 'Moving Tidewater from Postgres to SQLite' + + - name: Show the server's output + if: always() + run: docker logs demo || true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72aa83e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# The hosted ChatLore demo: the made-up demo library behind the public web +# interface, API, and MCP endpoint. See docs/hosting.md. +# +# docker build -t chatlore-demo . +# docker run -p 7860:7860 -e OPENROUTER_API_KEY=... chatlore-demo + +FROM ghcr.io/astral-sh/uv:0.12-python3.12-trixie-slim + +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /app + +# Dependencies first, so changing the code does not reinstall them. +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-dev --no-install-project +COPY README.md LICENSE ./ +COPY src ./src +RUN uv sync --locked --no-dev --no-editable + +# Hugging Face Spaces and many other hosts run containers as user 1000. +RUN useradd --create-home --uid 1000 chatlore +USER chatlore +ENV HOME=/home/chatlore PATH=/app/.venv/bin:$PATH + +# Load the demo library and download the embedding model while building, so +# the container answers as soon as it starts. +RUN chatlore demo --no-serve \ + && chatlore --home "$HOME/.chatlore-demo" search --semantic "warm up" > /dev/null + +EXPOSE 7860 +# PORT is set by hosts such as Render and Fly.io; Hugging Face Spaces uses 7860. +CMD ["sh", "-c", "exec chatlore --home \"$HOME/.chatlore-demo\" serve --public --host 0.0.0.0 --port \"${PORT:-7860}\""] From 564023d702eb36e6a867e066697e84073dc80a11 Mon Sep 17 00:00:00 2001 From: Cl0ver Date: Fri, 25 Sep 2026 14:00:19 -0400 Subject: [PATCH 3/4] docs: describe hosting the demo and connecting assistants over HTTP docs/hosting.md covers the image, the model key and its limits, what --public changes, and what a host needs. The MCP guide gains a section on /mcp, locally and on the hosted demo, for ChatGPT, Claude, and Cursor. --- CHANGELOG.md | 13 +++++++++ README.md | 5 ++-- docs/hosting.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 28 +++++++++++++++++-- 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 docs/hosting.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 986267f..4471f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `chatlore serve --public` for hosting a demo behind a proxy: questions sent to the model are + limited for each visitor per hour and for everyone per day (`--questions-per-hour`, + `--questions-per-day`), visitors are told apart by the proxy's forwarded address, and the web + interface says it is a demo. `/health` reports whether a server is public. +- `chatlore serve` also serves the MCP server at `/mcp` over streamable HTTP, for assistants + that connect to a URL, such as ChatGPT. A private server only answers MCP requests addressed + to this machine. +- A `Dockerfile` for the hosted demo, with the demo library and embedding model built in. CI + builds it, starts it, and checks the web interface, the API, and a tool call over MCP. Guide + in `docs/hosting.md`. + ## [0.1.0] - 2026-09-24 ### Added diff --git a/README.md b/README.md index e5486ed..5b824eb 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,9 @@ Extraction and chat use any OpenAI-compatible model, OpenRouter by default; see [docs/models.md](docs/models.md). The knowledge graph is described in [docs/extraction.md](docs/extraction.md), chat and the API in [docs/chat.md](docs/chat.md), the web interface in [docs/web.md](docs/web.md), -setting up assistants over MCP in [docs/mcp.md](docs/mcp.md), and archives and -Markdown export in [docs/export.md](docs/export.md). +setting up assistants over MCP in [docs/mcp.md](docs/mcp.md), archives and +Markdown export in [docs/export.md](docs/export.md), and hosting the demo in +[docs/hosting.md](docs/hosting.md). ## What ChatLore will do diff --git a/docs/hosting.md b/docs/hosting.md new file mode 100644 index 0000000..562f9ea --- /dev/null +++ b/docs/hosting.md @@ -0,0 +1,73 @@ +# Hosting the demo + +```bash +docker build -t chatlore-demo . +docker run -p 7860:7860 -e OPENROUTER_API_KEY=sk-or-... chatlore-demo +``` + +The image in the repository's `Dockerfile` is the hosted demo: the made-up +library from `chatlore demo`, served to anyone who opens it. Visitors get the +web interface at `/`, the REST API with its docs at `/docs`, and the MCP server +at `/mcp`, so they can connect ChatGPT, Claude, or another assistant to it +without installing anything. + +The demo library and the embedding model are built into the image, so a +container answers as soon as it starts. It used about 400 MB of memory once +searches had loaded the embedding model; give it 1 GB to be safe. It listens on +port 7860, or on `$PORT` when the host sets it. + +## The model key + +Search, conversations, topics, the graph, and every MCP tool work without a +key. Only asking in the web interface or through `POST /chat` needs a language +model, and that is paid for with the host's key. Pass it as a secret, never in +the image: + +| Variable | Meaning | +|---|---| +| `OPENROUTER_API_KEY` | Key for OpenRouter, the default provider. | +| `CHATLORE_LLM_MODEL`, `CHATLORE_LLM_BASE_URL` | Another model or provider; see [models.md](models.md). | + +Without a key, asking says that a key is needed and everything else works. +OpenRouter lets a key have a credit limit; give the demo's key one. + +## Public mode + +The image runs `chatlore serve --public`. Anyone can run the same on a library +they are happy for anyone to read: + +```bash +chatlore --home ~/.chatlore-demo serve --public --host 0.0.0.0 --port 7860 +``` + +`--public` is for a server behind a proxy on the internet, such as the HTTPS +front of a hosting service: + +- Questions that reach the language model are limited: 10 an hour for each + visitor and 300 a day for everyone together, by default. Change them with + `--questions-per-hour` and `--questions-per-day`. A question that finds no + passages is not counted, since it never reaches the model. When a limit is + reached, the visitor is told why and how to install ChatLore instead. +- Visitors are told apart by the address the proxy reports, from + `X-Forwarded-For`. Without a proxy in front, visitors could claim any address, + but the daily limit still holds. +- `/mcp` accepts requests for any host name. A private server only answers + requests addressed to this machine, which keeps websites from reaching it + through a visitor's browser. +- The web interface says it is a demo, and `/health` reports `"public": true`. + +The library is only ever read. Never serve your own library this way: anyone +who finds the address can read every conversation in it. + +## Where to host it + +Any service that builds and runs a Dockerfile from a Git repository works, and +gives the container an HTTPS address. ChatGPT only connects to HTTPS. + +CI builds the image on every pull request, starts it, and checks the web +interface, the API, and a tool call over MCP. + +## Connecting assistants to it + +The MCP endpoint is the demo's address followed by `/mcp`, with no +authentication. See [mcp.md](mcp.md#over-http) for each assistant. diff --git a/docs/mcp.md b/docs/mcp.md index 3d40419..384da50 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -80,8 +80,32 @@ project, or `.cursor/mcp.json` for one. **Other clients.** Any MCP client that starts local servers works the same way: the command is `chatlore` with the argument `mcp`. ChatGPT only connects to -servers on the internet, which a private library should not be; that will come -with the hosted demo. +servers on the internet, so it uses the hosted demo, below. + +## Over HTTP + +`chatlore serve` serves the same tools at `/mcp` over streamable HTTP, for +assistants that connect to a URL instead of starting a command: + +```bash +chatlore serve # then: +claude mcp add --transport http chatlore http://127.0.0.1:8000/mcp +``` + +On your machine, `/mcp` only answers requests addressed to `127.0.0.1` or +`localhost`, so websites cannot reach it through your browser. + +The hosted demo serves its made-up library the same way at its address followed +by `/mcp`, with no authentication (see [hosting.md](hosting.md)): + +- **ChatGPT**: in developer mode, add a connector with that URL and no + authentication. +- **Claude**: add it as a custom connector, or in Claude Code with + `claude mcp add --transport http chatlore-demo `. +- **Cursor**: an `mcpServers` entry with `"url": ""` instead of a command. + +Never put your own library on the internet this way: anyone with the address +could read it. ## Tools From c573ac0125dbb5864078fe2f93a547f1f807b331 Mon Sep 17 00:00:00 2001 From: Cl0ver Date: Fri, 25 Sep 2026 14:00:54 -0400 Subject: [PATCH 4/4] docs: update the roadmap for the hosted demo The server, MCP over HTTP, and the image are done; deploying the demo is the part of M9 left, and ChatGPT connects once it is online. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b824eb..abb2045 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ extraction is an optional enrichment you can re-run with a better model later. | M4 | Entity, topic, and fact extraction with provenance | entities and topics done; facts later | | M5 | REST API with streaming chat | done | | M6 | Web UI: conversations, graph explorer, chat | basic version done | -| M7 | MCP server for Claude Desktop, Claude Code, Cursor, ChatGPT | done for local assistants; ChatGPT with the hosted demo | +| M7 | MCP server for Claude Desktop, Claude Code, Cursor, ChatGPT | done; ChatGPT through /mcp once the demo is online | | M8 | Easy to try: PyPI package, demo library, export and import | done, v0.1.0 | -| M9 | Hosted demo | planned | +| M9 | Hosted demo | public mode, MCP over HTTP, and Docker image done; deployment next | | M10 | FalkorDB backend | planned | ## Development setup