lorehub-mcp 0.1.0__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,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lostwood Games
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.5
2
+ Name: lorehub-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server that lets AI agents read the Meta Areas of a LoreHub dialogue
5
+ Project-URL: Homepage, https://lorehub.app
6
+ Project-URL: Documentation, https://lorehub.app/mcp
7
+ Author: Lostwood Games
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agents,dialogue,game-development,lorehub,mcp,model-context-protocol,narrative
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Games/Entertainment
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: mcp<3,>=2.3
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # LoreHub MCP
30
+
31
+ <!-- mcp-name: io.github.lostwood-games/lorehub-mcp -->
32
+
33
+ An MCP server that lets AI agents (Claude, Cursor and any other MCP client) read the **Meta Areas** of a [LoreHub](https://lorehub.app) dialogue.
34
+
35
+ A director draws Meta Areas on the Dialogue Builder canvas: a shot, a location, a mood, with a note, a priority and meta rules such as `shot = close_up`. With this server, your agent reads those areas and the nodes inside them, and turns them into references, shot lists or mood boards. It only reads: it never changes your world.
36
+
37
+ More on the [LoreHub MCP page](https://lorehub.app/mcp) and in the [Meta Areas guide](https://lorehub.app/meta-areas-guide).
38
+
39
+ ## Install
40
+
41
+ You need a LoreHub account with at least Viewer access to the world, and [uv](https://docs.astral.sh/uv/getting-started/installation/). Nothing else to install: `uvx` downloads and runs the server.
42
+
43
+ ```bash
44
+ uvx lorehub-mcp
45
+ ```
46
+
47
+ Or with pip: `pip install lorehub-mcp`, then run `lorehub-mcp`.
48
+
49
+ ### Claude Code
50
+
51
+ ```bash
52
+ claude mcp add lorehub --env LOREHUB_LOGIN=you@example.com --env LOREHUB_PASSWORD=your-password -- uvx lorehub-mcp
53
+ ```
54
+
55
+ ### Claude Desktop
56
+
57
+ Settings → Developer → Edit Config, then add to `claude_desktop_config.json`:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "lorehub": {
63
+ "command": "uvx",
64
+ "args": ["lorehub-mcp"],
65
+ "env": {
66
+ "LOREHUB_LOGIN": "you@example.com",
67
+ "LOREHUB_PASSWORD": "your-password"
68
+ }
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ ### Cursor
75
+
76
+ Add the same block to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project):
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "lorehub": {
82
+ "command": "uvx",
83
+ "args": ["lorehub-mcp"],
84
+ "env": {
85
+ "LOREHUB_LOGIN": "you@example.com",
86
+ "LOREHUB_PASSWORD": "your-password"
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ Other MCP clients work the same way: run `uvx lorehub-mcp` over stdio with the two environment variables.
94
+
95
+ ## Find your world and dialogue ids
96
+
97
+ You don't need to look them up yourself. Ask the agent, for example *"List my LoreHub worlds, then find the dialogue Tom at Grandpa's and give me its Meta Areas"*, and it calls `list_worlds`, `list_dialogues` and `get_dialogue_meta` in turn.
98
+
99
+ To copy them by hand: open the dialogue in the Dialogue Builder. The address looks like `app.lorehub.app/<owner>/<world>/game/<story id>?dialogue=<dialogue id>`, so the dialogue id is after `?dialogue=`. The world id comes from `list_worlds`.
100
+
101
+ ## Tools
102
+
103
+ | Tool | What it returns |
104
+ | --- | --- |
105
+ | `list_worlds()` | The worlds your account can open, with their ids and your role. |
106
+ | `list_dialogues(world_id, game_id?, name?)` | Stories and their dialogues with ids. `name` filters by dialogue name. |
107
+ | `get_dialogue_meta(world_id, dialogue_id, area?, include_nodes=true)` | Each Meta Area's title, note, priority and meta rules as `field name: value`, the nodes inside it, and the nodes where areas overlap (higher priority first). `area` filters by title text or area id. |
108
+
109
+ Example output of `get_dialogue_meta`:
110
+
111
+ ```json
112
+ {
113
+ "dialogue": "Tom at Grandpa's",
114
+ "dialogue_id": "…",
115
+ "areas": [
116
+ {"title": "Grandpa's house: wide", "priority": 1,
117
+ "rules": {"shot": "wide", "background": "grandpas_house"},
118
+ "nodes": ["dlg.n_01", "dlg.n_04"], "id": "…"},
119
+ {"title": "Close-up: Tom is crying", "note": "Tears, low light", "priority": 2,
120
+ "rules": {"shot": "close_up", "background": "grandpas_house", "tom_emotion": "crying"},
121
+ "nodes": ["dlg.n_04"], "id": "…"}
122
+ ],
123
+ "overlaps": [{"node": "dlg.n_04", "areas": ["Close-up: Tom is crying (P2)", "Grandpa's house: wide (P1)"]}]
124
+ }
125
+ ```
126
+
127
+ ## Configuration
128
+
129
+ | Variable | Default | |
130
+ | --- | --- | --- |
131
+ | `LOREHUB_LOGIN`, `LOREHUB_PASSWORD` | | Your LoreHub user name or email and password. The server signs in, and signs in again when the token expires. |
132
+ | `LOREHUB_TOKEN` | | A LoreHub JWT instead of a login and password. It lasts 8 hours. |
133
+ | `LOREHUB_API_URL` | `https://api.lorehub.app` | |
134
+ | `LOREHUB_TIMEOUT` | `30` | Seconds per request. |
135
+ | `LOREHUB_LOG_LEVEL` | `INFO` | Logs go to stderr. |
136
+ | `LOREHUB_NODE_WIDTH`, `LOREHUB_NODE_HEADER_HEIGHT` | `200`, `32` | The canvas node card size. Leave them as they are. |
137
+
138
+ ## How it works
139
+
140
+ The server makes read-only GET calls to the LoreHub API with your account's token: the dialogue's Meta Areas, its name and story, the story's meta schema (to turn rule ids into field names) and the node positions. A node is inside an area when the middle of its header is, the same rule the Dialogue Builder uses.
141
+
142
+ Not yet: nodes come back as full ids, without their line text or speaker.
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,118 @@
1
+ # LoreHub MCP
2
+
3
+ <!-- mcp-name: io.github.lostwood-games/lorehub-mcp -->
4
+
5
+ An MCP server that lets AI agents (Claude, Cursor and any other MCP client) read the **Meta Areas** of a [LoreHub](https://lorehub.app) dialogue.
6
+
7
+ A director draws Meta Areas on the Dialogue Builder canvas: a shot, a location, a mood, with a note, a priority and meta rules such as `shot = close_up`. With this server, your agent reads those areas and the nodes inside them, and turns them into references, shot lists or mood boards. It only reads: it never changes your world.
8
+
9
+ More on the [LoreHub MCP page](https://lorehub.app/mcp) and in the [Meta Areas guide](https://lorehub.app/meta-areas-guide).
10
+
11
+ ## Install
12
+
13
+ You need a LoreHub account with at least Viewer access to the world, and [uv](https://docs.astral.sh/uv/getting-started/installation/). Nothing else to install: `uvx` downloads and runs the server.
14
+
15
+ ```bash
16
+ uvx lorehub-mcp
17
+ ```
18
+
19
+ Or with pip: `pip install lorehub-mcp`, then run `lorehub-mcp`.
20
+
21
+ ### Claude Code
22
+
23
+ ```bash
24
+ claude mcp add lorehub --env LOREHUB_LOGIN=you@example.com --env LOREHUB_PASSWORD=your-password -- uvx lorehub-mcp
25
+ ```
26
+
27
+ ### Claude Desktop
28
+
29
+ Settings → Developer → Edit Config, then add to `claude_desktop_config.json`:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "lorehub": {
35
+ "command": "uvx",
36
+ "args": ["lorehub-mcp"],
37
+ "env": {
38
+ "LOREHUB_LOGIN": "you@example.com",
39
+ "LOREHUB_PASSWORD": "your-password"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### Cursor
47
+
48
+ Add the same block to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project):
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "lorehub": {
54
+ "command": "uvx",
55
+ "args": ["lorehub-mcp"],
56
+ "env": {
57
+ "LOREHUB_LOGIN": "you@example.com",
58
+ "LOREHUB_PASSWORD": "your-password"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ Other MCP clients work the same way: run `uvx lorehub-mcp` over stdio with the two environment variables.
66
+
67
+ ## Find your world and dialogue ids
68
+
69
+ You don't need to look them up yourself. Ask the agent, for example *"List my LoreHub worlds, then find the dialogue Tom at Grandpa's and give me its Meta Areas"*, and it calls `list_worlds`, `list_dialogues` and `get_dialogue_meta` in turn.
70
+
71
+ To copy them by hand: open the dialogue in the Dialogue Builder. The address looks like `app.lorehub.app/<owner>/<world>/game/<story id>?dialogue=<dialogue id>`, so the dialogue id is after `?dialogue=`. The world id comes from `list_worlds`.
72
+
73
+ ## Tools
74
+
75
+ | Tool | What it returns |
76
+ | --- | --- |
77
+ | `list_worlds()` | The worlds your account can open, with their ids and your role. |
78
+ | `list_dialogues(world_id, game_id?, name?)` | Stories and their dialogues with ids. `name` filters by dialogue name. |
79
+ | `get_dialogue_meta(world_id, dialogue_id, area?, include_nodes=true)` | Each Meta Area's title, note, priority and meta rules as `field name: value`, the nodes inside it, and the nodes where areas overlap (higher priority first). `area` filters by title text or area id. |
80
+
81
+ Example output of `get_dialogue_meta`:
82
+
83
+ ```json
84
+ {
85
+ "dialogue": "Tom at Grandpa's",
86
+ "dialogue_id": "…",
87
+ "areas": [
88
+ {"title": "Grandpa's house: wide", "priority": 1,
89
+ "rules": {"shot": "wide", "background": "grandpas_house"},
90
+ "nodes": ["dlg.n_01", "dlg.n_04"], "id": "…"},
91
+ {"title": "Close-up: Tom is crying", "note": "Tears, low light", "priority": 2,
92
+ "rules": {"shot": "close_up", "background": "grandpas_house", "tom_emotion": "crying"},
93
+ "nodes": ["dlg.n_04"], "id": "…"}
94
+ ],
95
+ "overlaps": [{"node": "dlg.n_04", "areas": ["Close-up: Tom is crying (P2)", "Grandpa's house: wide (P1)"]}]
96
+ }
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ | Variable | Default | |
102
+ | --- | --- | --- |
103
+ | `LOREHUB_LOGIN`, `LOREHUB_PASSWORD` | | Your LoreHub user name or email and password. The server signs in, and signs in again when the token expires. |
104
+ | `LOREHUB_TOKEN` | | A LoreHub JWT instead of a login and password. It lasts 8 hours. |
105
+ | `LOREHUB_API_URL` | `https://api.lorehub.app` | |
106
+ | `LOREHUB_TIMEOUT` | `30` | Seconds per request. |
107
+ | `LOREHUB_LOG_LEVEL` | `INFO` | Logs go to stderr. |
108
+ | `LOREHUB_NODE_WIDTH`, `LOREHUB_NODE_HEADER_HEIGHT` | `200`, `32` | The canvas node card size. Leave them as they are. |
109
+
110
+ ## How it works
111
+
112
+ The server makes read-only GET calls to the LoreHub API with your account's token: the dialogue's Meta Areas, its name and story, the story's meta schema (to turn rule ids into field names) and the node positions. A node is inside an area when the middle of its header is, the same rule the Dialogue Builder uses.
113
+
114
+ Not yet: nodes come back as full ids, without their line text or speaker.
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "lorehub-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server that lets AI agents read the Meta Areas of a LoreHub dialogue"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Lostwood Games" }]
9
+ requires-python = ">=3.10"
10
+ keywords = ["mcp", "model-context-protocol", "lorehub", "dialogue", "game-development", "narrative", "ai-agents"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Games/Entertainment",
21
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
+ ]
23
+ dependencies = [
24
+ "mcp>=2.3,<3",
25
+ "httpx>=0.27",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://lorehub.app"
30
+ Documentation = "https://lorehub.app/mcp"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8", "pytest-asyncio>=0.23"]
34
+
35
+ [project.scripts]
36
+ lorehub-mcp = "lorehub_mcp.server:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling>=1.27"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/lorehub_mcp"]
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.lostwood-games/lorehub-mcp",
4
+ "title": "LoreHub Meta Areas",
5
+ "description": "Read the Meta Areas a director marks on a LoreHub dialogue: notes, priorities, rules and the nodes inside.",
6
+ "websiteUrl": "https://lorehub.app/mcp",
7
+ "version": "0.1.0",
8
+ "packages": [
9
+ {
10
+ "registryType": "pypi",
11
+ "registryBaseUrl": "https://pypi.org",
12
+ "identifier": "lorehub-mcp",
13
+ "version": "0.1.0",
14
+ "runtimeHint": "uvx",
15
+ "transport": {
16
+ "type": "stdio"
17
+ },
18
+ "environmentVariables": [
19
+ {
20
+ "name": "LOREHUB_LOGIN",
21
+ "description": "LoreHub user name or email. The server signs in with it and signs in again when the token expires.",
22
+ "isSecret": true
23
+ },
24
+ {
25
+ "name": "LOREHUB_PASSWORD",
26
+ "description": "LoreHub password for LOREHUB_LOGIN.",
27
+ "isSecret": true
28
+ },
29
+ {
30
+ "name": "LOREHUB_TOKEN",
31
+ "description": "A LoreHub JWT, instead of a login and password. It lasts 8 hours.",
32
+ "isSecret": true
33
+ },
34
+ {
35
+ "name": "LOREHUB_API_URL",
36
+ "description": "LoreHub API address.",
37
+ "default": "https://api.lorehub.app"
38
+ }
39
+ ]
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1 @@
1
+ """LoreHub Meta Areas MCP server."""
@@ -0,0 +1,120 @@
1
+ """A small async client for the parts of the LoreHub REST API this server reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ log = logging.getLogger("lorehub_mcp")
13
+
14
+ DEFAULT_API_URL = "https://api.lorehub.app"
15
+
16
+
17
+ class LoreHubError(Exception):
18
+ """A LoreHub call failed in a way the caller should see (bad id, no access, API down)."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Settings:
23
+ api_url: str
24
+ token: str | None
25
+ login: str | None
26
+ password: str | None
27
+ node_width: int
28
+ node_header_height: int
29
+ timeout: float
30
+
31
+ @classmethod
32
+ def from_env(cls) -> "Settings":
33
+ return cls(
34
+ api_url=os.environ.get("LOREHUB_API_URL", DEFAULT_API_URL).rstrip("/"),
35
+ token=os.environ.get("LOREHUB_TOKEN") or None,
36
+ login=os.environ.get("LOREHUB_LOGIN") or None,
37
+ password=os.environ.get("LOREHUB_PASSWORD") or None,
38
+ # The Dialogue Builder's node card: a node is inside an area when the middle of its header is.
39
+ node_width=int(os.environ.get("LOREHUB_NODE_WIDTH", "200")),
40
+ node_header_height=int(os.environ.get("LOREHUB_NODE_HEADER_HEIGHT", "32")),
41
+ timeout=float(os.environ.get("LOREHUB_TIMEOUT", "30")),
42
+ )
43
+
44
+
45
+ class LoreHubClient:
46
+ """Authenticates with a LoreHub JWT. Tokens last 8 hours, so with a login and password set the client signs in
47
+ again once when a call comes back 401."""
48
+
49
+ def __init__(self, settings: Settings, transport: httpx.AsyncBaseTransport | None = None):
50
+ if not settings.token and not (settings.login and settings.password):
51
+ raise LoreHubError("Set LOREHUB_TOKEN, or LOREHUB_LOGIN and LOREHUB_PASSWORD.")
52
+ self.settings = settings
53
+ self._token = settings.token
54
+ self._http = httpx.AsyncClient(base_url=settings.api_url, timeout=settings.timeout, transport=transport)
55
+
56
+ async def aclose(self) -> None:
57
+ await self._http.aclose()
58
+
59
+ # --- endpoints -------------------------------------------------------------------------------------------
60
+
61
+ async def get_worlds(self) -> list[dict[str, Any]]:
62
+ return await self._get("/api/Setting")
63
+
64
+ async def get_meta_areas(self, world_id: str, dialogue_id: str) -> dict[str, Any]:
65
+ return await self._get(f"/api/v3/worlds/{world_id}/dialogues/{dialogue_id}/metaareas")
66
+
67
+ async def get_dialogue(self, world_id: str, dialogue_id: str) -> dict[str, Any]:
68
+ return await self._get(f"/api/v2/Dialogue/{world_id}/{dialogue_id}")
69
+
70
+ async def get_dialogues_for_game(self, world_id: str, game_id: str) -> list[dict[str, Any]]:
71
+ return await self._get(f"/api/v2/Dialogue/GetForGame/{world_id}/{game_id}")
72
+
73
+ async def get_games(self, world_id: str) -> list[dict[str, Any]]:
74
+ return await self._get(f"/api/v3/worlds/{world_id}/games")
75
+
76
+ async def get_nodes(self, world_id: str, dialogue_id: str) -> list[dict[str, Any]]:
77
+ return await self._get(f"/api/DialogNode/{world_id}/{dialogue_id}")
78
+
79
+ async def get_meta_schemas(self, world_id: str, game_id: str) -> list[dict[str, Any]]:
80
+ return await self._get(f"/api/MetaSchemaGame/{world_id}/{game_id}")
81
+
82
+ # --- plumbing --------------------------------------------------------------------------------------------
83
+
84
+ async def _sign_in(self) -> None:
85
+ if not (self.settings.login and self.settings.password):
86
+ raise LoreHubError("LoreHub rejected LOREHUB_TOKEN (it lasts 8 hours). Set a fresh token, or LOREHUB_LOGIN and LOREHUB_PASSWORD.")
87
+ log.info("Signing in to LoreHub as %s", self.settings.login)
88
+ r = await self._http.post("/api/Account/Login", json={"UserNameOrMail": self.settings.login, "Password": self.settings.password})
89
+ if r.status_code != 200:
90
+ raise LoreHubError(f"LoreHub sign-in failed ({r.status_code}). Check LOREHUB_LOGIN and LOREHUB_PASSWORD.")
91
+ self._token = r.json().get("token")
92
+ if not self._token:
93
+ raise LoreHubError("LoreHub sign-in returned no token.")
94
+
95
+ async def _get(self, path: str) -> Any:
96
+ if not self._token:
97
+ await self._sign_in()
98
+ r = await self._send(path)
99
+ if r.status_code == 401 and self.settings.login and self.settings.password:
100
+ await self._sign_in()
101
+ r = await self._send(path)
102
+ if r.status_code == 401:
103
+ raise LoreHubError("LoreHub rejected the token (it lasts 8 hours). Set a fresh LOREHUB_TOKEN, or LOREHUB_LOGIN and LOREHUB_PASSWORD.")
104
+ if r.status_code == 403:
105
+ raise LoreHubError("No access to this world. The LoreHub account needs at least Viewer access.")
106
+ if r.status_code == 404:
107
+ raise LoreHubError(f"Not found in LoreHub: {path}. Check the world and dialogue ids.")
108
+ if r.status_code >= 400:
109
+ raise LoreHubError(f"LoreHub returned {r.status_code} for {path}: {r.text[:300]}")
110
+ return r.json()
111
+
112
+ async def _send(self, path: str) -> httpx.Response:
113
+ log.debug("GET %s", path)
114
+ try:
115
+ return await self._http.get(path, headers={"Authorization": f"Bearer {self._token}"})
116
+ except httpx.TimeoutException as ex:
117
+ # The test API scales to zero, so the first call after a quiet period can be slow.
118
+ raise LoreHubError(f"LoreHub did not answer within {self.settings.timeout:g}s ({path}). Try again.") from ex
119
+ except httpx.HTTPError as ex:
120
+ raise LoreHubError(f"Could not reach LoreHub at {self.settings.api_url}: {ex}") from ex
@@ -0,0 +1,62 @@
1
+ """Turns LoreHub's Meta Area data into the compact shape the tools return. Pure functions, so they are easy to test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def node_inside(area: dict[str, Any], node: dict[str, Any], node_width: int, header_height: int) -> bool:
9
+ """LoreHub's rule (NodeRect.ContainsHeaderMiddle): a node is inside when the middle of its header is inside the
10
+ area's rectangle, left and top edges included."""
11
+ mx = node["posX"] + node_width // 2
12
+ my = node["posY"] + header_height // 2
13
+ return area["x"] <= mx < area["x"] + area["width"] and area["y"] <= my < area["y"] + area["height"]
14
+
15
+
16
+ def matches(area: dict[str, Any], query: str) -> bool:
17
+ q = query.strip().lower()
18
+ return q == str(area["id"]).lower() or q in (area.get("title") or "").lower()
19
+
20
+
21
+ def shape_areas(
22
+ areas: list[dict[str, Any]],
23
+ schemas: list[dict[str, Any]],
24
+ nodes: list[dict[str, Any]] | None,
25
+ node_width: int,
26
+ header_height: int,
27
+ ) -> list[dict[str, Any]]:
28
+ """One entry per area, in canvas reading order (top to bottom, then left to right). Rules are keyed by the meta
29
+ schema field's name. Empty and default values are left out to keep the output small."""
30
+ names = {str(s["id"]).lower(): s.get("name") or str(s["id"]) for s in schemas}
31
+ out: list[dict[str, Any]] = []
32
+ for a in sorted(areas, key=lambda a: (a["y"], a["x"])):
33
+ item: dict[str, Any] = {"title": a.get("title") or "(untitled)"}
34
+ if a.get("note"):
35
+ item["note"] = a["note"]
36
+ item["priority"] = a.get("priority", 1)
37
+ rules = {names.get(str(r["definitionId"]).lower(), f"unknown field {r['definitionId']}"): r["value"] for r in a.get("rules") or []}
38
+ if rules:
39
+ item["rules"] = rules
40
+ if not a.get("rulesEnabled", True):
41
+ item["rules_enabled"] = False
42
+ if nodes is not None:
43
+ inside = [n for n in nodes if node_inside(a, n, node_width, header_height)]
44
+ inside.sort(key=lambda n: (n["posY"], n["posX"]))
45
+ item["nodes"] = [n.get("fullId") or n.get("osfId") or str(n["id"]) for n in inside]
46
+ item["id"] = a["id"]
47
+ out.append(item)
48
+ return out
49
+
50
+
51
+ def overlaps(shaped: list[dict[str, Any]]) -> list[dict[str, Any]]:
52
+ """Nodes inside more than one area, with the areas in priority order, so an agent knows which context wins."""
53
+ by_node: dict[str, list[dict[str, Any]]] = {}
54
+ for a in shaped:
55
+ for n in a.get("nodes", []):
56
+ by_node.setdefault(n, []).append(a)
57
+ result = []
58
+ for node, areas in by_node.items():
59
+ if len(areas) > 1:
60
+ ordered = sorted(areas, key=lambda a: -a["priority"])
61
+ result.append({"node": node, "areas": [f"{a['title']} (P{a['priority']})" for a in ordered]})
62
+ return result
@@ -0,0 +1,128 @@
1
+ """LoreHub Meta Areas MCP server: hands an agent the Meta Areas a director marked on a dialogue's canvas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import sys
9
+ from typing import Annotated, Any
10
+
11
+ from mcp.server.mcpserver import MCPServer
12
+ from mcp.server.mcpserver.exceptions import ToolError
13
+ from mcp_types import ToolAnnotations
14
+ from pydantic import Field
15
+
16
+ from .client import LoreHubClient, LoreHubError, Settings
17
+ from .meta import matches, overlaps, shape_areas
18
+
19
+ # stdio carries the protocol on stdout, so every log line goes to stderr.
20
+ logging.basicConfig(stream=sys.stderr, level=os.environ.get("LOREHUB_LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s: %(message)s")
21
+ log = logging.getLogger("lorehub_mcp")
22
+
23
+ mcp = MCPServer(
24
+ name="lorehub-meta",
25
+ instructions=(
26
+ "Reads the Meta Areas a director drew on a LoreHub dialogue: each area's title, note, priority, meta rules "
27
+ "(field = value) and the nodes inside it. Use get_dialogue_meta when you have a world id and dialogue id. "
28
+ "If you only know the world, story or dialogue name, call list_worlds and list_dialogues first. Read-only."
29
+ ),
30
+ )
31
+
32
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True)
33
+
34
+ WorldId = Annotated[str, Field(description="LoreHub world (setting) id, a GUID.")]
35
+
36
+ _client: LoreHubClient | None = None
37
+
38
+
39
+ def client() -> LoreHubClient:
40
+ global _client
41
+ if _client is None:
42
+ try:
43
+ _client = LoreHubClient(Settings.from_env())
44
+ except LoreHubError as ex:
45
+ raise ToolError(str(ex)) from ex
46
+ return _client
47
+
48
+
49
+ @mcp.tool(annotations=READ_ONLY)
50
+ async def get_dialogue_meta(
51
+ world_id: WorldId,
52
+ dialogue_id: Annotated[str, Field(description="Dialogue id, a GUID.")],
53
+ area: Annotated[str | None, Field(description="Only areas whose title contains this text (case-insensitive), or an area id. Omit for all areas.")] = None,
54
+ include_nodes: Annotated[bool, Field(description="List the nodes inside each area (by full id) and the nodes where areas overlap.")] = True,
55
+ ) -> dict[str, Any]:
56
+ """Get the Meta Areas of one dialogue: what the director marked on the canvas, ready to hand to a reference-
57
+ generating agent. Each area has its title, note, priority (higher wins where areas overlap), its meta rules as
58
+ field name -> value, and the nodes inside it. Areas are in canvas reading order."""
59
+ c = client()
60
+ try:
61
+ dialogue, data = await asyncio.gather(c.get_dialogue(world_id, dialogue_id), c.get_meta_areas(world_id, dialogue_id))
62
+ areas = [a for a in data.get("areas", []) if area is None or matches(a, area)]
63
+ game_id = dialogue.get("gameId")
64
+ needs_schema = any(a.get("rules") for a in areas)
65
+ schemas, nodes = await asyncio.gather(
66
+ c.get_meta_schemas(world_id, game_id) if needs_schema and game_id else _none([]),
67
+ c.get_nodes(world_id, dialogue_id) if include_nodes and areas else _none(None),
68
+ )
69
+ except LoreHubError as ex:
70
+ raise ToolError(str(ex)) from ex
71
+
72
+ shaped = shape_areas(areas, schemas, nodes, c.settings.node_width, c.settings.node_header_height)
73
+ result: dict[str, Any] = {"dialogue": dialogue.get("name") or dialogue_id, "dialogue_id": dialogue_id, "areas": shaped}
74
+ if include_nodes and (o := overlaps(shaped)):
75
+ result["overlaps"] = o
76
+ if not shaped:
77
+ result["message"] = f"No Meta Areas match '{area}'." if area and data.get("areas") else "This dialogue has no Meta Areas."
78
+ log.info("get_dialogue_meta %s: %d of %d areas", dialogue_id, len(shaped), len(data.get("areas", [])))
79
+ return result
80
+
81
+
82
+ @mcp.tool(annotations=READ_ONLY)
83
+ async def list_worlds() -> dict[str, Any]:
84
+ """List the LoreHub worlds this account can open, with their ids, to pass to list_dialogues and get_dialogue_meta."""
85
+ try:
86
+ worlds = await client().get_worlds()
87
+ except LoreHubError as ex:
88
+ raise ToolError(str(ex)) from ex
89
+ return {"worlds": [
90
+ {"id": w["id"], "name": w.get("name") or "", "url": f"{w.get('ownerUserName')}/{w.get('urlName')}", "role": w.get("currentUserRole")}
91
+ for w in worlds
92
+ ]}
93
+
94
+
95
+ @mcp.tool(annotations=READ_ONLY)
96
+ async def list_dialogues(
97
+ world_id: WorldId,
98
+ game_id: Annotated[str | None, Field(description="Story (game) id. Omit to list the dialogues of every story in the world.")] = None,
99
+ name: Annotated[str | None, Field(description="Only dialogues whose name contains this text (case-insensitive).")] = None,
100
+ ) -> dict[str, Any]:
101
+ """Find a dialogue's id by story and name, to pass to get_dialogue_meta."""
102
+ c = client()
103
+ try:
104
+ games = [{"id": game_id, "name": None}] if game_id else await c.get_games(world_id)
105
+ lists = await asyncio.gather(*(c.get_dialogues_for_game(world_id, g["id"]) for g in games))
106
+ except LoreHubError as ex:
107
+ raise ToolError(str(ex)) from ex
108
+
109
+ q = (name or "").lower()
110
+ stories = []
111
+ for g, dialogues in zip(games, lists):
112
+ found = [{"id": d["id"], "name": d.get("name") or ""} for d in dialogues if q in (d.get("name") or "").lower()]
113
+ if found:
114
+ stories.append({"story": g.get("name") or g["id"], "story_id": g["id"], "dialogues": found})
115
+ return {"stories": stories} if stories else {"stories": [], "message": "No dialogues found."}
116
+
117
+
118
+ async def _none(value: Any) -> Any:
119
+ return value
120
+
121
+
122
+ def main() -> None:
123
+ log.info("Starting lorehub-meta MCP server (stdio)")
124
+ mcp.run()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,130 @@
1
+ """Runs the tools against a fake LoreHub API built from the Meta Areas spec's example."""
2
+
3
+ import httpx
4
+ import pytest
5
+ from mcp.server.mcpserver.exceptions import ToolError
6
+
7
+ from lorehub_mcp import server
8
+ from lorehub_mcp.client import LoreHubClient, Settings
9
+ from lorehub_mcp.meta import node_inside
10
+
11
+ W, D, G = "w1", "d1", "g1"
12
+ SHOT, BG, EMO = "11111111-0000-0000-0000-000000000001", "11111111-0000-0000-0000-000000000002", "11111111-0000-0000-0000-000000000003"
13
+
14
+ AREAS = {
15
+ "dialogueId": D,
16
+ "version": 7,
17
+ "areas": [
18
+ {"id": "a-close", "title": "Close-up: Tom is crying", "note": "Tears, low light", "color": "#f00", "x": 300, "y": 0, "width": 400,
19
+ "height": 300, "priority": 2, "keepValuesOnLeave": False, "rulesEnabled": True,
20
+ "rules": [{"definitionId": SHOT, "value": "close_up"}, {"definitionId": BG, "value": "grandpas_house"}, {"definitionId": EMO, "value": "crying"}]},
21
+ {"id": "a-wide", "title": "Grandpa's house: wide", "note": "", "color": "#0f0", "x": 0, "y": 0, "width": 600, "height": 300,
22
+ "priority": 1, "keepValuesOnLeave": False, "rulesEnabled": True,
23
+ "rules": [{"definitionId": SHOT, "value": "wide"}, {"definitionId": BG, "value": "grandpas_house"}]},
24
+ {"id": "a-note", "title": "Rewrite later", "note": "Too long", "color": "#00f", "x": 0, "y": 1000, "width": 100, "height": 100,
25
+ "priority": 1, "keepValuesOnLeave": False, "rulesEnabled": False, "rules": []},
26
+ ],
27
+ }
28
+ NODES = [
29
+ {"id": "n1", "fullId": "dlg.n_01", "posX": 10, "posY": 10}, # header middle (110, 26): wide only
30
+ {"id": "n4", "fullId": "dlg.n_04", "posX": 320, "posY": 50}, # (420, 66): both
31
+ {"id": "n9", "fullId": "dlg.n_09", "posX": 2000, "posY": 2000}, # outside
32
+ ]
33
+ SCHEMAS = [{"id": SHOT, "name": "shot", "type": "Text"}, {"id": BG, "name": "background", "type": "Text"}, {"id": EMO, "name": "tom_emotion", "type": "Text"}]
34
+
35
+
36
+ def fake_api(token="good", calls=None):
37
+ def handler(req: httpx.Request) -> httpx.Response:
38
+ if calls is not None:
39
+ calls.append(req.url.path)
40
+ if req.url.path == "/api/Account/Login":
41
+ return httpx.Response(200, json={"token": "good"})
42
+ if req.headers.get("authorization") != f"Bearer {token}":
43
+ return httpx.Response(401)
44
+ routes = {
45
+ f"/api/v2/Dialogue/{W}/{D}": {"id": D, "gameId": G, "name": "Tom at Grandpa's"},
46
+ f"/api/v3/worlds/{W}/dialogues/{D}/metaareas": AREAS,
47
+ f"/api/DialogNode/{W}/{D}": NODES,
48
+ f"/api/MetaSchemaGame/{W}/{G}": SCHEMAS,
49
+ "/api/Setting": [{"id": W, "name": "Tom's World", "urlName": "toms-world", "ownerUserName": "andrei", "currentUserRole": "Owner"}],
50
+ f"/api/v3/worlds/{W}/games": [{"id": G, "name": "Episode 1"}],
51
+ f"/api/v2/Dialogue/GetForGame/{W}/{G}": [{"id": D, "name": "Tom at Grandpa's"}, {"id": "d2", "name": "Intro"}],
52
+ }
53
+ return httpx.Response(200, json=routes[req.url.path]) if req.url.path in routes else httpx.Response(404)
54
+
55
+ return httpx.MockTransport(handler)
56
+
57
+
58
+ def use(settings_kw=None, **api_kw):
59
+ kw = dict(api_url="https://api.test", token="good", login=None, password=None, node_width=200, node_header_height=32, timeout=5)
60
+ kw.update(settings_kw or {})
61
+ server._client = LoreHubClient(Settings(**kw), transport=fake_api(**api_kw))
62
+
63
+
64
+ async def test_returns_areas_with_named_rules_nodes_and_overlaps():
65
+ use()
66
+ r = await server.get_dialogue_meta(W, D)
67
+ assert r["dialogue"] == "Tom at Grandpa's"
68
+ wide, close, note = r["areas"] # reading order: y, then x
69
+ assert wide["title"] == "Grandpa's house: wide" and wide["nodes"] == ["dlg.n_01", "dlg.n_04"]
70
+ assert close["rules"] == {"shot": "close_up", "background": "grandpas_house", "tom_emotion": "crying"}
71
+ assert close["note"] == "Tears, low light" and close["priority"] == 2 and close["nodes"] == ["dlg.n_04"]
72
+ assert "rules" not in note and note["nodes"] == []
73
+ assert "note" not in wide
74
+ assert r["overlaps"] == [{"node": "dlg.n_04", "areas": ["Close-up: Tom is crying (P2)", "Grandpa's house: wide (P1)"]}]
75
+
76
+
77
+ async def test_area_filter_and_no_nodes():
78
+ calls = []
79
+ use(calls=calls)
80
+ r = await server.get_dialogue_meta(W, D, area="close-UP", include_nodes=False)
81
+ assert [a["title"] for a in r["areas"]] == ["Close-up: Tom is crying"]
82
+ assert "nodes" not in r["areas"][0] and "overlaps" not in r
83
+ assert f"/api/DialogNode/{W}/{D}" not in calls
84
+ r = await server.get_dialogue_meta(W, D, area="nothing")
85
+ assert r["areas"] == [] and "No Meta Areas match" in r["message"]
86
+
87
+
88
+ async def test_signs_in_again_when_token_expired():
89
+ use({"token": "expired", "login": "me@x", "password": "pw"})
90
+ r = await server.get_dialogue_meta(W, D, include_nodes=False)
91
+ assert len(r["areas"]) == 3
92
+
93
+
94
+ async def test_expired_token_without_login_is_a_clear_tool_error():
95
+ use({"token": "expired"})
96
+ with pytest.raises(ToolError, match="8 hours"):
97
+ await server.get_dialogue_meta(W, D)
98
+
99
+
100
+ async def test_unknown_dialogue_is_a_tool_error():
101
+ use()
102
+ with pytest.raises(ToolError, match="Not found"):
103
+ await server.get_dialogue_meta(W, "nope")
104
+
105
+
106
+ async def test_list_dialogues_by_name():
107
+ use()
108
+ r = await server.list_dialogues(W, name="grandpa")
109
+ assert r == {"stories": [{"story": "Episode 1", "story_id": G, "dialogues": [{"id": D, "name": "Tom at Grandpa's"}]}]}
110
+
111
+
112
+ async def test_list_worlds():
113
+ use()
114
+ r = await server.list_worlds()
115
+ assert r == {"worlds": [{"id": W, "name": "Tom's World", "url": "andrei/toms-world", "role": "Owner"}]}
116
+
117
+
118
+ def test_inside_uses_header_middle_with_left_top_edges_included():
119
+ area = {"x": 100, "y": 16, "width": 10, "height": 10}
120
+ assert node_inside(area, {"posX": 0, "posY": 0}, 200, 32) # middle (100, 16) on the corner
121
+ assert not node_inside({**area, "x": 101}, {"posX": 0, "posY": 0}, 200, 32)
122
+ assert not node_inside({"x": 0, "y": 0, "width": 100, "height": 16}, {"posX": 0, "posY": 0}, 200, 32) # right/bottom excluded
123
+
124
+
125
+ async def test_tools_are_listed_with_schemas():
126
+ tools = {t.name: t for t in await server.mcp.list_tools()}
127
+ assert set(tools) == {"get_dialogue_meta", "list_dialogues", "list_worlds"}
128
+ props = tools["get_dialogue_meta"].input_schema["properties"]
129
+ assert set(props) == {"world_id", "dialogue_id", "area", "include_nodes"}
130
+ assert tools["get_dialogue_meta"].annotations.read_only_hint