lorebase-herald 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ .eggs/
11
+ .venv/
12
+ venv/
13
+ .python-eggs/
14
+ pip-wheel-metadata/
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .pyre/
19
+ .pytype/
20
+ .hypothesis/
21
+ .coverage
22
+ .coverage.*
23
+ coverage.xml
24
+ htmlcov/
25
+ .tox/
26
+ .nox/
27
+ *.prof
28
+
29
+ # Node
30
+ node_modules/
31
+ .pnpm-store/
32
+ .npm/
33
+ .yarn/
34
+ !.yarn/releases
35
+ .pnp.*
36
+ npm-debug.log*
37
+ yarn-debug.log*
38
+ yarn-error.log*
39
+ pnpm-debug.log*
40
+ .vite/
41
+ *.tsbuildinfo
42
+ apps/web/dist/
43
+ apps/web/.vitest/
44
+
45
+ # Environment and secrets
46
+ .env
47
+ .env.*
48
+ !.env.example
49
+ *.pem
50
+ *.key
51
+ secrets/
52
+
53
+ # Docker
54
+ docker-compose.override.yaml
55
+ docker-compose.override.yml
56
+ .docker/
57
+
58
+ # Data and local artifacts
59
+ *.sqlite
60
+ *.db
61
+ /tmp/
62
+ /data/
63
+ tests/perf/results/
64
+
65
+ # Editors and OS
66
+ .idea/
67
+ .vscode/*
68
+ !.vscode/extensions.json
69
+ !.vscode/settings.example.json
70
+ *.swp
71
+ *.swo
72
+ *~
73
+ .DS_Store
74
+ Thumbs.db
75
+ *.code-workspace
76
+ .history/
77
+
78
+ # Session worktrees
79
+ .claude/
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: lorebase-herald
3
+ Version: 0.2.3
4
+ Summary: Lorebase Herald: MCP server over the Keeper REST API
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: lorebase-client<0.3,>=0.2
8
+ Requires-Dist: lorebase-platform<0.3,>=0.2
9
+ Requires-Dist: mcp<3,>=2.3
10
+ Requires-Dist: opentelemetry-api>=1.25
11
+ Requires-Dist: pydantic>=2.7
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "lorebase-herald"
3
+ version = "0.2.3"
4
+ description = "Lorebase Herald: MCP server over the Keeper REST API"
5
+ requires-python = ">=3.12"
6
+ # mcp: the official Model Context Protocol SDK (tools, resources and prompts; stdio and
7
+ # Streamable HTTP transports; in-memory client sessions for tests).
8
+ # lorebase-client: the typed client of Keeper's REST API (requests, models, errors, retries).
9
+ # httpx: transport options of that client (TLS verification, test transports).
10
+ # pydantic: tool input schemas and the `herald` settings section.
11
+ # opentelemetry-api: trace context of Herald's own spans, propagated to Keeper as `traceparent`.
12
+ # lorebase-platform: layered settings, secret references and structured logging.
13
+ dependencies = [
14
+ "lorebase-platform>=0.2,<0.3",
15
+ "lorebase-client>=0.2,<0.3",
16
+ "mcp>=2.3,<3",
17
+ "httpx>=0.27",
18
+ "opentelemetry-api>=1.25",
19
+ "pydantic>=2.7",
20
+ ]
21
+
22
+ [project.scripts]
23
+ lorebase-herald = "lorebase_herald.__main__:main"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/lorebase_herald"]
@@ -0,0 +1,3 @@
1
+ """Lorebase Herald: MCP server exposing a Lorebase Domain through Keeper's REST API."""
2
+
3
+ __version__ = "0.2.3"
@@ -0,0 +1,40 @@
1
+ """Run Herald: `lorebase-herald` or `python -m lorebase_herald`.
2
+
3
+ The transport (stdio or Streamable HTTP) and everything else come from the `herald` settings.
4
+ Logs go to stderr: on stdio, stdout carries the MCP protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import logging
12
+ import sys
13
+
14
+ from lorebase_herald.keeper import KeeperClients
15
+ from lorebase_herald.server import serve
16
+ from lorebase_herald.settings import herald_settings
17
+ from lorebase_platform.logging import configure_logging
18
+ from lorebase_platform.settings import ConfigError, load_settings
19
+
20
+ SERVICE_NAME = "herald"
21
+
22
+
23
+ def main() -> None:
24
+ """Load settings, configure logging and serve until the host disconnects."""
25
+ try:
26
+ settings = load_settings()
27
+ herald = herald_settings(settings)
28
+ except ConfigError as exc:
29
+ sys.exit(str(exc))
30
+ configure_logging(settings.logging, service=SERVICE_NAME, stream=sys.stderr)
31
+ # httpx logs every Keeper request URL at INFO; keep it quiet so record keys stay out of the
32
+ # logs (on stdio they would land in the agent's stderr).
33
+ for name in ("httpx", "httpcore"):
34
+ logging.getLogger(name).setLevel(logging.WARNING)
35
+ with contextlib.suppress(KeyboardInterrupt):
36
+ asyncio.run(serve(herald, KeeperClients(herald.keeper)))
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,173 @@
1
+ """Client errors mapped to tool errors a model can act on.
2
+
3
+ A tool error is one short text: `[code] what happened` followed by `next:` with the action
4
+ that resolves it. `code` is Keeper's stable problem code (design 11.2: machine-readable code
5
+ and a short human message), or a Herald code for failures before Keeper answers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping
11
+ from typing import Any
12
+
13
+ import httpx
14
+ from mcp.server.mcpserver.exceptions import ToolError
15
+ from pydantic import ValidationError
16
+
17
+ from lorebase_client import (
18
+ ApiError,
19
+ AuthenticationError,
20
+ IdempotencyConflictError,
21
+ LorebaseConnectionError,
22
+ NotFoundError,
23
+ PermissionDeniedError,
24
+ ProtectedBranchError,
25
+ RateLimitedError,
26
+ ReviewRequiredError,
27
+ ServerError,
28
+ StaleHeadError,
29
+ ValidationFailedError,
30
+ )
31
+
32
+ HintFactory = Callable[[ApiError], str]
33
+
34
+
35
+ def _stale_head(error: ApiError) -> str:
36
+ current = error.current_head
37
+ return (
38
+ f"the branch head is now {current}; re-read what the change depends on, then retry "
39
+ f"with expected_head={current}"
40
+ if current
41
+ else "re-read the branch head and retry"
42
+ )
43
+
44
+
45
+ def _review(error: ApiError) -> str:
46
+ proposals = error.extra.get("proposals")
47
+ where = f" ({proposals})" if proposals else ""
48
+ return (
49
+ "this change needs review: call again with task and intent so that Keeper runs it on "
50
+ "a task branch and opens the Proposal, or run it on a work branch yourself and open a "
51
+ f"Proposal for that branch{where}; do not retry here as it is"
52
+ )
53
+
54
+
55
+ def _retry_later(error: ApiError) -> str:
56
+ after = error.retry_after if error.retry_after is not None else error.extra.get("retry_after")
57
+ if after is None:
58
+ return "retry later"
59
+ seconds = f"{after:g}" if isinstance(after, int | float) else after
60
+ return f"retry after {seconds} s"
61
+
62
+
63
+ # Codes that no exception class of the client stands for.
64
+ HINTS_BY_CODE: Mapping[str, HintFactory] = {
65
+ "expressions-not-enabled": lambda _: "this Operation cannot run on this deployment",
66
+ "branch-not-materialized": lambda _: "read this branch at a Revision with `at`",
67
+ "limit-exceeded": lambda _: "narrow the request",
68
+ }
69
+ # Most specific class first.
70
+ HINTS_BY_TYPE: tuple[tuple[type[ApiError], HintFactory], ...] = (
71
+ (StaleHeadError, _stale_head),
72
+ (ReviewRequiredError, _review),
73
+ (ProtectedBranchError, _review),
74
+ (IdempotencyConflictError, lambda _: "use a new idempotency_key for different parameters"),
75
+ (ValidationFailedError, lambda _: "fix the listed members and call again"),
76
+ (
77
+ NotFoundError,
78
+ lambda _: "check the ref with lorebase_search or names with lorebase_schema",
79
+ ),
80
+ (PermissionDeniedError, lambda _: "this agent's API key lacks the permission; do not retry"),
81
+ (AuthenticationError, lambda _: "the agent's API key is missing, expired or revoked"),
82
+ (RateLimitedError, _retry_later),
83
+ (ServerError, _retry_later),
84
+ )
85
+
86
+
87
+ def _hint(error: ApiError) -> HintFactory | None:
88
+ by_code = HINTS_BY_CODE.get(error.code or "")
89
+ if by_code is not None:
90
+ return by_code
91
+ return next((hint for kind, hint in HINTS_BY_TYPE if isinstance(error, kind)), None)
92
+
93
+
94
+ def _issues(extra: Mapping[str, Any], limit: int) -> list[str]:
95
+ """Validation findings (`issues`) or request member errors (`errors`), one line each."""
96
+ lines: list[str] = []
97
+ found = extra.get("issues") or extra.get("errors") or []
98
+ if not isinstance(found, list):
99
+ return lines
100
+ for issue in found[:limit]:
101
+ if not isinstance(issue, Mapping):
102
+ continue
103
+ where = issue.get("pointer") or "/".join(str(p) for p in issue.get("path") or [])
104
+ code = issue.get("code")
105
+ subject = "/".join(
106
+ str(issue[k]) for k in ("kind", "key", "attribute") if issue.get(k) is not None
107
+ )
108
+ location = " ".join(part for part in (where, f"({subject})" if subject else "") if part)
109
+ suffix = f" [{code}]" if code else ""
110
+ lines.append(f"- {location or '<request>'}: {issue.get('message', '')}{suffix}")
111
+ total = extra.get("issue_count")
112
+ if isinstance(total, int) and total > len(lines):
113
+ lines.append(f"- ... {total - len(lines)} more")
114
+ elif len(found) > limit:
115
+ lines.append(f"- ... {len(found) - limit} more")
116
+ return lines
117
+
118
+
119
+ def tool_error(error: ApiError | LorebaseConnectionError, *, max_issues: int) -> ToolError:
120
+ """The tool error answering a failed Keeper call."""
121
+ if isinstance(error, LorebaseConnectionError):
122
+ return ToolError(f"[keeper-unavailable] {_unreachable(error)}. next: retry later")
123
+ # A problem always has a code; an answer without one is not Keeper's (a proxy, say) and its
124
+ # body is no message for a model.
125
+ message = (error.detail or error.title) if error.code else error.title
126
+ lines = [f"[{error.code or f'http-{error.status}'}] {message or 'Error'}"]
127
+ lines.extend(_issues(error.extra, max_issues))
128
+ hint = _hint(error)
129
+ if hint is not None:
130
+ lines.append(f"next: {hint(error)}")
131
+ return ToolError("\n".join(lines))
132
+
133
+
134
+ def _unreachable(error: LorebaseConnectionError) -> str:
135
+ cause = error.__cause__
136
+ if isinstance(cause, httpx.TimeoutException):
137
+ return "Keeper did not answer in time"
138
+ return f"Keeper is unreachable ({type(cause).__name__ if cause else 'no connection'})"
139
+
140
+
141
+ def _findings(error: ValidationError, limit: int) -> list[str]:
142
+ found = error.errors(include_url=False, include_context=False, include_input=False)
143
+ lines = [
144
+ f"- {'/'.join(str(p) for p in finding['loc']) or '<request>'}: {finding['msg']} "
145
+ f"[{finding['type']}]"
146
+ for finding in found[:limit]
147
+ ]
148
+ if len(found) > limit:
149
+ lines.append(f"- ... {len(found) - limit} more")
150
+ return lines
151
+
152
+
153
+ def invalid_request(error: ValidationError, *, max_issues: int) -> ToolError:
154
+ """The tool error for a request the client's models refuse before anything is sent."""
155
+ lines = ["[validation-failed] the request is not valid", *_findings(error, max_issues)]
156
+ lines.append("next: fix the listed members and call again")
157
+ return ToolError("\n".join(lines))
158
+
159
+
160
+ def unexpected_answer(error: ValidationError) -> ToolError:
161
+ """The tool error for an answer that does not fit the client's response models."""
162
+ first = error.errors(include_url=False, include_context=False, include_input=False)[0]
163
+ where = "/".join(str(p) for p in first["loc"])
164
+ return ToolError(
165
+ f"[keeper-response-invalid] Keeper's answer does not fit this Herald's API model "
166
+ f"({error.error_count()} problems, first at {where}: {first['msg']}).\n"
167
+ "next: use matching Herald and Keeper versions"
168
+ )
169
+
170
+
171
+ def invalid_input(code: str, message: str, hint: str) -> ToolError:
172
+ """A tool error raised by Herald before calling Keeper."""
173
+ return ToolError(f"[{code}] {message}\nnext: {hint}")
@@ -0,0 +1,64 @@
1
+ """Keeper access for the tools: one typed Lorebase client per request.
2
+
3
+ Herald reaches Lorebase only through Keeper (AC-20), so authorization, masking, audit and rate
4
+ limits are enforced in one place. Every client carries the credential of the calling agent and
5
+ nothing else: no client is shared between requests, so no credential, connection or cached
6
+ answer can cross from one caller to another.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator
12
+ from contextlib import asynccontextmanager
13
+
14
+ import httpx
15
+
16
+ from lorebase_client import AsyncLorebaseClient, RetryPolicy
17
+ from lorebase_herald.settings import KeeperConnection
18
+ from lorebase_herald.tracing import call_headers, stamp_request
19
+
20
+
21
+ def bearer_token(credential: str | None) -> str | None:
22
+ """The token of a credential: a forwarded `Bearer ...` value or a bare API key."""
23
+ if credential is None:
24
+ return None
25
+ scheme, _, rest = credential.partition(" ")
26
+ return rest.strip() if rest and scheme.lower() == "bearer" else credential
27
+
28
+
29
+ class KeeperClients:
30
+ """Opens the client of one request. `transport` replaces the network (tests)."""
31
+
32
+ def __init__(
33
+ self, connection: KeeperConnection, *, transport: httpx.AsyncBaseTransport | None = None
34
+ ) -> None:
35
+ self._connection = connection
36
+ self._transport = transport
37
+
38
+ def _transport_for_request(self) -> httpx.AsyncBaseTransport | None:
39
+ if self._transport is None and not self._connection.verify_tls:
40
+ return httpx.AsyncHTTPTransport(verify=False)
41
+ return self._transport
42
+
43
+ @asynccontextmanager
44
+ async def open(self, credential: str | None) -> AsyncIterator[AsyncLorebaseClient]:
45
+ """A client for one request, closed afterwards. With no credential, requests go out
46
+ anonymous: the client reads no key from the environment. It carries the trace headers of
47
+ the tool call being served; each of its requests gets a span of its own (see
48
+ `lorebase_herald.tracing`)."""
49
+ connection = self._connection
50
+ async with AsyncLorebaseClient(
51
+ connection.base_url,
52
+ api_key=bearer_token(credential),
53
+ api_prefix=connection.api_prefix,
54
+ timeout=connection.timeout_s,
55
+ retry=RetryPolicy(
56
+ max_retries=connection.max_retries, backoff_max_s=connection.retry_max_wait_s
57
+ ),
58
+ headers={"User-Agent": connection.user_agent, **(call_headers.get() or {})},
59
+ transport=self._transport_for_request(),
60
+ event_hooks={"request": [stamp_request]},
61
+ trust_env=connection.trust_env,
62
+ env={},
63
+ ) as client:
64
+ yield client
@@ -0,0 +1,45 @@
1
+ """Server instructions and the `lorebase_guide` prompt template (no model calls)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ INSTRUCTIONS = """\
6
+ Lorebase is a versioned knowledge graph: typed Records (refs `Kind/key`) linked by Relations,
7
+ on branches of immutable Revisions. Workflow:
8
+ 1. Orient: lorebase_context (records or a question) or lorebase_schema.
9
+ 2. Read narrowly: lorebase_get, lorebase_query, lorebase_search, lorebase_neighborhood;
10
+ lorebase_explain / lorebase_lineage for where a value came from; lorebase_history for a
11
+ Record's changes; lorebase_changes / lorebase_diff for history.
12
+ 3. Write only with lorebase_execute: dry_run first (the default), then dry_run=false with
13
+ expected_head. Some Operations need review: pass task and intent and Keeper opens the
14
+ Proposal on a task branch for you.
15
+ Outputs carry the Revision they were read at and say when they were cut."""
16
+
17
+ GUIDE = """\
18
+ You are working with the Lorebase Domain {domain}.
19
+
20
+ How to use Lorebase:
21
+ - Records are addressed as `Kind/key` (for example `Supplier/S-1001`); use api names exactly as
22
+ lorebase_schema prints them.
23
+ - Start with lorebase_context: pass the Records the task is about, or a short question; it
24
+ returns those Records, their links, nearby Records and the schema of the Kinds involved,
25
+ within a token budget.
26
+ - Then read only what you need: lorebase_get for one Record, lorebase_query for filtered lists
27
+ (JSON DSL), lorebase_search to find Records by words, lorebase_neighborhood for links.
28
+ - Facts carry provenance: lorebase_explain shows who or what set each value and when.
29
+ - History: lorebase_changes lists Revisions since one you saw; lorebase_diff compares two.
30
+ - Writes go only through governed Operations with lorebase_execute. Always dry-run first
31
+ (dry_run is true by default), check edits and violations, then commit with dry_run=false and
32
+ expected_head set to the head the dry run reported. A `review-required` or
33
+ `protected-branch` error means the change must go through a Proposal: repeat the call with
34
+ `task` and `intent` and send an `idempotency_key`; Keeper opens the Proposal on a task branch.
35
+ - Errors start with `[code]` and end with `next:`; follow that step instead of retrying blindly.
36
+ - Outputs are cut at a size limit and say so; narrow the request or follow `next_cursor`.
37
+ {task}"""
38
+
39
+
40
+ def guide(domain: str | None, task: str | None) -> str:
41
+ """The guide text for a Domain and, optionally, the task at hand."""
42
+ return GUIDE.format(
43
+ domain=domain or "configured for this server",
44
+ task=f"\nTask: {task}" if task else "",
45
+ )
@@ -0,0 +1,82 @@
1
+ """Rendering helpers shared by the tools: compact values, list caps and size caps.
2
+
3
+ Text output is written for a model's context window: one fact per line, `Kind/key` refs,
4
+ no decoration. Every cut is visible: lists end with `... N more` and over-long outputs end
5
+ with a `[truncated ...]` marker, never a silent cut.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Mapping, Sequence
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ from pydantic import TypeAdapter
16
+
17
+ from lorebase_herald.settings import OutputSettings
18
+
19
+ ELLIPSIS = "…"
20
+ _TIMESTAMP = TypeAdapter(datetime)
21
+
22
+
23
+ def ref(kind: str, key: str) -> str:
24
+ """The `Kind/key` reference of a Record."""
25
+ return f"{kind}/{key}"
26
+
27
+
28
+ def as_json(value: Any) -> str:
29
+ """Compact JSON (no spaces, UTF-8 kept)."""
30
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
31
+
32
+
33
+ def timestamp(value: datetime) -> str:
34
+ """A timestamp as the API writes it: RFC 3339, `Z` for UTC."""
35
+ return str(_TIMESTAMP.dump_python(value, mode="json"))
36
+
37
+
38
+ def value_text(value: Any, max_chars: int) -> str:
39
+ """A value on one line: strings as they are, other values as compact JSON."""
40
+ text = value if isinstance(value, str) else as_json(value)
41
+ text = text.replace("\n", " ")
42
+ return text if len(text) <= max_chars else text[: max_chars - 1] + ELLIPSIS
43
+
44
+
45
+ def values_text(values: Mapping[str, Any], max_chars: int, separator: str = "; ") -> str:
46
+ """`name=value` pairs on one line."""
47
+ return separator.join(f"{k}={value_text(v, max_chars)}" for k, v in values.items())
48
+
49
+
50
+ def titled(reference: str, title: str | None) -> str:
51
+ return f'{reference} "{title}"' if title else reference
52
+
53
+
54
+ def capped[T](items: Sequence[T], limit: int) -> tuple[list[T], int]:
55
+ """The first `limit` items and how many were left out."""
56
+ return list(items[:limit]), max(0, len(items) - limit)
57
+
58
+
59
+ def more_line(omitted: int, hint: str = "") -> list[str]:
60
+ """`... N more` when items were left out."""
61
+ if omitted <= 0:
62
+ return []
63
+ return [f"... {omitted} more{f' ({hint})' if hint else ''}"]
64
+
65
+
66
+ def finish(text: str, output: OutputSettings) -> str:
67
+ """Cut an output longer than `max_chars` at a line break, with a marker saying so."""
68
+ if len(text) <= output.max_chars:
69
+ return text
70
+ budget = output.max_chars - 120
71
+ cut = text.rfind("\n", 0, budget)
72
+ cut = budget if cut <= 0 else cut
73
+ omitted = len(text) - cut
74
+ return (
75
+ text[:cut] + f"\n[truncated: {omitted} more chars; narrow the request, lower `limit` "
76
+ "or follow `next_cursor`]"
77
+ )
78
+
79
+
80
+ def render(view: Mapping[str, Any], text: str, fmt: str, output: OutputSettings) -> str:
81
+ """The final tool output: compact JSON of `view` or `text`, within the size cap."""
82
+ return finish(as_json(view) if fmt == "json" else text, output)