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.
- lorehub_mcp-0.1.0/.gitignore +4 -0
- lorehub_mcp-0.1.0/LICENSE +21 -0
- lorehub_mcp-0.1.0/PKG-INFO +146 -0
- lorehub_mcp-0.1.0/README.md +118 -0
- lorehub_mcp-0.1.0/pyproject.toml +47 -0
- lorehub_mcp-0.1.0/server.json +42 -0
- lorehub_mcp-0.1.0/src/lorehub_mcp/__init__.py +1 -0
- lorehub_mcp-0.1.0/src/lorehub_mcp/client.py +120 -0
- lorehub_mcp-0.1.0/src/lorehub_mcp/meta.py +62 -0
- lorehub_mcp-0.1.0/src/lorehub_mcp/server.py +128 -0
- lorehub_mcp-0.1.0/tests/test_server.py +130 -0
|
@@ -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
|