tru8-mcp 1.0.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.
- tru8_mcp-1.0.0/.gitignore +6 -0
- tru8_mcp-1.0.0/LICENSE +21 -0
- tru8_mcp-1.0.0/PKG-INFO +110 -0
- tru8_mcp-1.0.0/README.md +90 -0
- tru8_mcp-1.0.0/pyproject.toml +31 -0
- tru8_mcp-1.0.0/server.json +53 -0
- tru8_mcp-1.0.0/smithery.yaml +23 -0
- tru8_mcp-1.0.0/tru8_mcp/__init__.py +1 -0
- tru8_mcp-1.0.0/tru8_mcp/__main__.py +5 -0
- tru8_mcp-1.0.0/tru8_mcp/server.py +149 -0
- tru8_mcp-1.0.0/tru8_mcp/tools.py +389 -0
tru8_mcp-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tru8
|
|
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.
|
tru8_mcp-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tru8-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Tru8 Evidence Research MCP server — structured evidence research tools for AI agents
|
|
5
|
+
Project-URL: Homepage, https://www.trueight.com/developers
|
|
6
|
+
Project-URL: Repository, https://github.com/SamYatesSmith/tru8-mcp
|
|
7
|
+
Project-URL: Documentation, https://www.trueight.com/developers
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agent,evidence,fact-check,mcp,research
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx>=0.25.0
|
|
18
|
+
Requires-Dist: mcp>=1.0.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# tru8-mcp
|
|
22
|
+
|
|
23
|
+
MCP server for [Tru8 Evidence Research](https://www.trueight.com) — structured evidence research tools for AI agents.
|
|
24
|
+
|
|
25
|
+
Submit a claim or URL, get back source-traced evidence organized by tier (primary/reporting/commentary) and type (data/official/news/analysis/opinion/academic), with element decomposition and relationship mapping.
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install tru8-mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or run without installing:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx tru8-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Set your API key:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export TRU8_API_KEY=tru8_sk_...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Create an API key at your [Tru8 dashboard](https://www.trueight.com/dashboard/settings) under Settings > Developer.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
### Claude Desktop
|
|
50
|
+
|
|
51
|
+
Add to `claude_desktop_config.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"tru8": {
|
|
57
|
+
"command": "tru8-mcp",
|
|
58
|
+
"env": {
|
|
59
|
+
"TRU8_API_KEY": "tru8_sk_..."
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Cline / Cursor
|
|
67
|
+
|
|
68
|
+
Add to your MCP settings:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"tru8": {
|
|
74
|
+
"command": "uvx",
|
|
75
|
+
"args": ["tru8-mcp"],
|
|
76
|
+
"env": {
|
|
77
|
+
"TRU8_API_KEY": "tru8_sk_..."
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### From source
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/tru8-evidence/tru8-mcp.git
|
|
88
|
+
cd tru8-mcp
|
|
89
|
+
pip install -e .
|
|
90
|
+
tru8-mcp
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Tools
|
|
94
|
+
|
|
95
|
+
| Tool | Description | Typical time |
|
|
96
|
+
|------|-------------|-------------|
|
|
97
|
+
| `tru8_check` | Evidence research for a claim or article URL | 15-120s |
|
|
98
|
+
| `tru8_get_result` | Retrieve completed check with computed analytics | <1s |
|
|
99
|
+
| `tru8_get_result_raw` | Retrieve raw check data without computed analytics | <1s |
|
|
100
|
+
|
|
101
|
+
## Environment Variables
|
|
102
|
+
|
|
103
|
+
| Variable | Required | Description |
|
|
104
|
+
|----------|----------|-------------|
|
|
105
|
+
| `TRU8_API_KEY` | Yes | API key (`tru8_sk_...`). Create at dashboard > Settings > Developer. |
|
|
106
|
+
| `TRU8_API_URL` | No | API base URL. Default: `https://api.trueight.com` |
|
|
107
|
+
|
|
108
|
+
## Security
|
|
109
|
+
|
|
110
|
+
Store API keys in environment variables or a secrets manager. Never hardcode keys in source code. If a key is compromised, revoke it immediately at your dashboard.
|
tru8_mcp-1.0.0/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# tru8-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [Tru8 Evidence Research](https://www.trueight.com) — structured evidence research tools for AI agents.
|
|
4
|
+
|
|
5
|
+
Submit a claim or URL, get back source-traced evidence organized by tier (primary/reporting/commentary) and type (data/official/news/analysis/opinion/academic), with element decomposition and relationship mapping.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install tru8-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or run without installing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uvx tru8-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Set your API key:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export TRU8_API_KEY=tru8_sk_...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Create an API key at your [Tru8 dashboard](https://www.trueight.com/dashboard/settings) under Settings > Developer.
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
### Claude Desktop
|
|
30
|
+
|
|
31
|
+
Add to `claude_desktop_config.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"tru8": {
|
|
37
|
+
"command": "tru8-mcp",
|
|
38
|
+
"env": {
|
|
39
|
+
"TRU8_API_KEY": "tru8_sk_..."
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Cline / Cursor
|
|
47
|
+
|
|
48
|
+
Add to your MCP settings:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"tru8": {
|
|
54
|
+
"command": "uvx",
|
|
55
|
+
"args": ["tru8-mcp"],
|
|
56
|
+
"env": {
|
|
57
|
+
"TRU8_API_KEY": "tru8_sk_..."
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### From source
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/tru8-evidence/tru8-mcp.git
|
|
68
|
+
cd tru8-mcp
|
|
69
|
+
pip install -e .
|
|
70
|
+
tru8-mcp
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Tools
|
|
74
|
+
|
|
75
|
+
| Tool | Description | Typical time |
|
|
76
|
+
|------|-------------|-------------|
|
|
77
|
+
| `tru8_check` | Evidence research for a claim or article URL | 15-120s |
|
|
78
|
+
| `tru8_get_result` | Retrieve completed check with computed analytics | <1s |
|
|
79
|
+
| `tru8_get_result_raw` | Retrieve raw check data without computed analytics | <1s |
|
|
80
|
+
|
|
81
|
+
## Environment Variables
|
|
82
|
+
|
|
83
|
+
| Variable | Required | Description |
|
|
84
|
+
|----------|----------|-------------|
|
|
85
|
+
| `TRU8_API_KEY` | Yes | API key (`tru8_sk_...`). Create at dashboard > Settings > Developer. |
|
|
86
|
+
| `TRU8_API_URL` | No | API base URL. Default: `https://api.trueight.com` |
|
|
87
|
+
|
|
88
|
+
## Security
|
|
89
|
+
|
|
90
|
+
Store API keys in environment variables or a secrets manager. Never hardcode keys in source code. If a key is compromised, revoke it immediately at your dashboard.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tru8-mcp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Tru8 Evidence Research MCP server — structured evidence research tools for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["mcp", "evidence", "fact-check", "ai-agent", "research"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"mcp>=1.0.0",
|
|
22
|
+
"httpx>=0.25.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://www.trueight.com/developers"
|
|
27
|
+
Repository = "https://github.com/SamYatesSmith/tru8-mcp"
|
|
28
|
+
Documentation = "https://www.trueight.com/developers"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
tru8-mcp = "tru8_mcp.server:main"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.tru8/mcp-server",
|
|
4
|
+
"title": "Tru8 Evidence Research",
|
|
5
|
+
"description": "Structured evidence research for AI agents. Ground factual claims in source-traced evidence organized by tier (primary data / news reporting / commentary) and type (data / official / news / analysis / opinion / academic). Returns element decomposition with relationship mapping (supports / challenges / context).",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"websiteUrl": "https://www.trueight.com/developers",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/SamYatesSmith/tru8-mcp",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"_note_package": "Publish to PyPI before submitting to MCP Registry",
|
|
15
|
+
"registryType": "pip",
|
|
16
|
+
"identifier": "tru8-mcp",
|
|
17
|
+
"version": "1.0.0",
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio"
|
|
20
|
+
},
|
|
21
|
+
"packageArguments": [],
|
|
22
|
+
"environmentVariables": [
|
|
23
|
+
{
|
|
24
|
+
"name": "TRU8_API_KEY",
|
|
25
|
+
"isRequired": true,
|
|
26
|
+
"isSecret": true,
|
|
27
|
+
"description": "Tru8 API key (tru8_sk_...). Create at dashboard Settings > Developer. Store in env vars, never hardcode."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "TRU8_API_URL",
|
|
31
|
+
"isRequired": false,
|
|
32
|
+
"isSecret": false,
|
|
33
|
+
"description": "API base URL. Default: https://api.trueight.com"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"runtimeHint": "python -m tru8_mcp"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"tools": [
|
|
40
|
+
{
|
|
41
|
+
"name": "tru8_check",
|
|
42
|
+
"description": "Evidence research for any claim or URL. Accepts max_tier (lookup/quick/full) to control depth and cost. Tier fallback: lookup → quick → full. Returns structured evidence landscape with element decomposition, source classification, and _meta (executedTier, chargedPence)."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "tru8_get_result",
|
|
46
|
+
"description": "Retrieve a completed check with pre-computed analytics: tier/type distributions, corroboration groups, diagnostic values, timeline, per-claim dispositions."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "tru8_get_result_raw",
|
|
50
|
+
"description": "Retrieve raw check data without computed analytics. Smaller payload for custom post-processing."
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Smithery configuration — https://smithery.ai/docs/config
|
|
2
|
+
# Tru8 MCP server: structured evidence research for AI agents
|
|
3
|
+
|
|
4
|
+
build:
|
|
5
|
+
dockerBuildPath: .
|
|
6
|
+
|
|
7
|
+
startCommand:
|
|
8
|
+
type: stdio
|
|
9
|
+
configSchema:
|
|
10
|
+
type: object
|
|
11
|
+
required:
|
|
12
|
+
- apiKey
|
|
13
|
+
properties:
|
|
14
|
+
apiKey:
|
|
15
|
+
type: string
|
|
16
|
+
description: "Tru8 API key (tru8_sk_...). Create at dashboard → Settings → Developer."
|
|
17
|
+
apiUrl:
|
|
18
|
+
type: string
|
|
19
|
+
description: "API base URL. Default: https://api.trueight.com"
|
|
20
|
+
default: "https://api.trueight.com"
|
|
21
|
+
commandFunction:
|
|
22
|
+
|-
|
|
23
|
+
(config) => ({ command: 'python', args: ['-m', 'tru8_mcp'], env: { TRU8_API_KEY: config.apiKey, TRU8_API_URL: config.apiUrl || 'https://api.trueight.com' } })
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tru8 MCP server — structured evidence research tools for AI agents."""
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Tru8 MCP server — structured evidence research tools for AI agents.
|
|
2
|
+
|
|
3
|
+
Run standalone:
|
|
4
|
+
python -m tru8_mcp
|
|
5
|
+
|
|
6
|
+
Configure for Claude Desktop (claude_desktop_config.json):
|
|
7
|
+
{
|
|
8
|
+
"mcpServers": {
|
|
9
|
+
"tru8": {
|
|
10
|
+
"command": "python",
|
|
11
|
+
"args": ["-m", "tru8_mcp"],
|
|
12
|
+
"env": {"TRU8_API_KEY": "tru8_sk_..."}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Environment variables:
|
|
18
|
+
TRU8_API_KEY — Required. Create at dashboard → Settings → Developer.
|
|
19
|
+
Store in env vars or a secrets manager — never hardcode in source.
|
|
20
|
+
TRU8_API_URL — Optional. Default: https://api.trueight.com
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
|
|
25
|
+
from mcp.server.fastmcp import FastMCP
|
|
26
|
+
|
|
27
|
+
from .tools import Tru8APIClient
|
|
28
|
+
|
|
29
|
+
mcp = FastMCP(
|
|
30
|
+
"tru8",
|
|
31
|
+
instructions=(
|
|
32
|
+
"Structured evidence research. Submit a factual claim or article URL. "
|
|
33
|
+
"Evidence is organised by source tier (primary/reporting/commentary) "
|
|
34
|
+
"and type (data/official/news/analysis/opinion/academic), with element "
|
|
35
|
+
"decomposition and relationship mapping (supports/challenges/context)."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_client: Tru8APIClient | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_client() -> Tru8APIClient:
|
|
43
|
+
"""Lazy-initialize the API client (reads env vars on first call)."""
|
|
44
|
+
global _client
|
|
45
|
+
if _client is None:
|
|
46
|
+
_client = Tru8APIClient()
|
|
47
|
+
return _client
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format(data: dict) -> str:
|
|
51
|
+
"""Serialize API response as indented JSON for agent consumption."""
|
|
52
|
+
return json.dumps(data, indent=2, default=str)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@mcp.tool()
|
|
56
|
+
async def tru8_check(
|
|
57
|
+
claim: str,
|
|
58
|
+
max_tier: str = "quick",
|
|
59
|
+
max_age_hours: int | None = None,
|
|
60
|
+
compact: bool = False,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Evidence research for a factual claim or article URL.
|
|
63
|
+
|
|
64
|
+
Submit a claim as text or paste an article URL. URLs are auto-detected
|
|
65
|
+
and trigger article mode: the pipeline extracts claims from the page
|
|
66
|
+
and auto-selects up to 5 for evidence research.
|
|
67
|
+
|
|
68
|
+
Tiers (in fallback order):
|
|
69
|
+
- lookup (~£0.02, instant) — cached prior analysis
|
|
70
|
+
- consensus (~£0.03, instant) — cross-user aggregate landscape (k≥3 checks)
|
|
71
|
+
- quick (~£0.07, ~15s) — web search + heuristic classification
|
|
72
|
+
- full (~£0.15, ~60-90s) — 30+ sources, LLM classification, coverage recovery
|
|
73
|
+
|
|
74
|
+
Charges based on tier actually executed, not tier requested.
|
|
75
|
+
Set max_tier to control maximum spend per call.
|
|
76
|
+
|
|
77
|
+
Output structure:
|
|
78
|
+
- claims[].claimMap.elements[] — verifiable sub-claims with state
|
|
79
|
+
(supported/disputed/unresolved)
|
|
80
|
+
- claims[].claimMap.elements[].evidenceRefs[] — evidence mapped to elements
|
|
81
|
+
with relationship (supports/challenges/context) and reasoning
|
|
82
|
+
- claims[].evidence[] — sources classified by tier (primary/reporting/
|
|
83
|
+
commentary) and type (data/official/news/analysis/opinion/academic)
|
|
84
|
+
- claims[].claimMap.orientation — mechanical summary from element states
|
|
85
|
+
- _meta — execution metadata: executedTier, chargedPence, limitations
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
claim: A factual claim ("The Earth's average temperature rose 1.1°C
|
|
89
|
+
since 1880") or an article URL (https://example.com/article).
|
|
90
|
+
URLs are auto-detected and the pipeline extracts claims from
|
|
91
|
+
the page content.
|
|
92
|
+
max_tier: Maximum tier to attempt — "lookup", "consensus", "quick" (default), or "full".
|
|
93
|
+
max_age_hours: Skip cache hits older than this many hours. If set,
|
|
94
|
+
lookup hits that are stale will be discarded and the
|
|
95
|
+
pipeline re-runs at the next tier up to max_tier.
|
|
96
|
+
compact: If True, strip full evidence arrays from response (smaller payload).
|
|
97
|
+
"""
|
|
98
|
+
client = _get_client()
|
|
99
|
+
result = await client.submit_with_fallback(
|
|
100
|
+
claim,
|
|
101
|
+
max_tier=max_tier,
|
|
102
|
+
max_age_hours=max_age_hours,
|
|
103
|
+
compact=compact,
|
|
104
|
+
)
|
|
105
|
+
return _format(result)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@mcp.tool()
|
|
109
|
+
async def tru8_get_result(check_id: str) -> str:
|
|
110
|
+
"""Retrieve a previously submitted check with pre-computed analytics.
|
|
111
|
+
|
|
112
|
+
Returns the full result including a _computed block with tier/type
|
|
113
|
+
distributions, corroboration groups, diagnostic values, timeline
|
|
114
|
+
analysis, element state summaries, and per-claim dispositions.
|
|
115
|
+
|
|
116
|
+
Use this over tru8_get_result_raw when you want structured analytics
|
|
117
|
+
ready for summarisation or comparison without post-processing.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
check_id: UUID returned by tru8_check.
|
|
121
|
+
"""
|
|
122
|
+
client = _get_client()
|
|
123
|
+
result = await client.get_check(check_id, computed=True)
|
|
124
|
+
return _format(result)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool()
|
|
128
|
+
async def tru8_get_result_raw(check_id: str) -> str:
|
|
129
|
+
"""Retrieve a previously submitted check without computed analytics.
|
|
130
|
+
|
|
131
|
+
Returns claims, elements, evidence, and claim maps only. No _computed
|
|
132
|
+
block. Smaller response payload. Use this when you will compute your
|
|
133
|
+
own aggregations or only need specific fields from the raw data.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
check_id: UUID returned by tru8_check.
|
|
137
|
+
"""
|
|
138
|
+
client = _get_client()
|
|
139
|
+
result = await client.get_check(check_id, computed=False)
|
|
140
|
+
return _format(result)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main():
|
|
144
|
+
"""Entry point for `python -m tru8_mcp`."""
|
|
145
|
+
mcp.run()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Tru8 API client for MCP tool implementations.
|
|
2
|
+
|
|
3
|
+
Wraps the Tru8 REST API with methods for submitting checks via the
|
|
4
|
+
synchronous /run endpoint (preferred) or agent tier endpoints (L-08).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Pipeline timeouts (seconds)
|
|
18
|
+
PIPELINE_TIMEOUT = 180.0
|
|
19
|
+
GET_TIMEOUT = 30.0
|
|
20
|
+
POLL_INTERVAL = 3.0
|
|
21
|
+
MAX_POLL_DURATION = 300.0
|
|
22
|
+
|
|
23
|
+
# Tier timeouts — quick is faster, so shorter HTTP timeout
|
|
24
|
+
TIER_TIMEOUTS = {
|
|
25
|
+
"lookup": 15.0,
|
|
26
|
+
"consensus": 15.0,
|
|
27
|
+
"quick": 60.0,
|
|
28
|
+
"full": PIPELINE_TIMEOUT + 30.0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Tier order for fallback
|
|
32
|
+
TIER_ORDER = ["lookup", "consensus", "quick", "full"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Tru8APIClient:
|
|
36
|
+
"""HTTP client wrapping the Tru8 Evidence Research API.
|
|
37
|
+
|
|
38
|
+
Authenticates via API key (X-API-Key header). Supports both the legacy
|
|
39
|
+
/checks/run endpoint and the agent tier endpoints (/agent/lookup, /quick, /full).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_url: Optional[str] = None,
|
|
45
|
+
api_key: Optional[str] = None,
|
|
46
|
+
):
|
|
47
|
+
self.base_url = (
|
|
48
|
+
api_url or os.environ.get("TRU8_API_URL", "https://api.trueight.com")
|
|
49
|
+
).rstrip("/")
|
|
50
|
+
self.api_key = api_key or os.environ.get("TRU8_API_KEY", "")
|
|
51
|
+
if not self.api_key:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"TRU8_API_KEY environment variable required. "
|
|
54
|
+
"Create one at your Tru8 dashboard → Settings → Developer."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _headers(self) -> dict:
|
|
58
|
+
return {"X-API-Key": self.api_key, "Accept": "application/json"}
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _detect_input_type(claim: str) -> Optional[str]:
|
|
62
|
+
"""Return "url" if claim looks like a URL, else None (server auto-detects)."""
|
|
63
|
+
if claim.strip().startswith(("http://", "https://")):
|
|
64
|
+
return "url"
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
async def submit_tier(
|
|
68
|
+
self,
|
|
69
|
+
claim: str,
|
|
70
|
+
tier: str,
|
|
71
|
+
compact: bool = False,
|
|
72
|
+
) -> dict:
|
|
73
|
+
"""Submit a claim or URL to a specific agent tier endpoint.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
claim: The claim text or article URL to analyse.
|
|
77
|
+
tier: "lookup", "quick", or "full".
|
|
78
|
+
compact: If True, strip evidence arrays from response.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The tier endpoint response dict (includes _meta block).
|
|
82
|
+
"""
|
|
83
|
+
timeout = TIER_TIMEOUTS.get(tier, PIPELINE_TIMEOUT)
|
|
84
|
+
payload: dict = {"claim": claim, "compact": compact}
|
|
85
|
+
input_type = self._detect_input_type(claim)
|
|
86
|
+
if input_type:
|
|
87
|
+
payload["input_type"] = input_type
|
|
88
|
+
|
|
89
|
+
async with httpx.AsyncClient(
|
|
90
|
+
timeout=httpx.Timeout(timeout, connect=10.0)
|
|
91
|
+
) as client:
|
|
92
|
+
resp = await client.post(
|
|
93
|
+
f"{self.base_url}/api/v1/agent/{tier}",
|
|
94
|
+
json=payload,
|
|
95
|
+
headers=self._headers(),
|
|
96
|
+
)
|
|
97
|
+
if resp.status_code == 402:
|
|
98
|
+
raise InsufficientBalanceError(f"Insufficient balance for {tier} tier")
|
|
99
|
+
if resp.status_code not in (200, 201):
|
|
100
|
+
raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
|
|
101
|
+
return resp.json()
|
|
102
|
+
|
|
103
|
+
async def submit_smart(
|
|
104
|
+
self,
|
|
105
|
+
claim: str,
|
|
106
|
+
max_tier: str = "quick",
|
|
107
|
+
max_age_hours: Optional[int] = None,
|
|
108
|
+
compact: bool = False,
|
|
109
|
+
) -> dict:
|
|
110
|
+
"""Submit via smart endpoint with server-side fallback (M-03).
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
claim: The claim text or article URL to analyse.
|
|
114
|
+
max_tier: Maximum tier to attempt ("lookup", "quick", or "full").
|
|
115
|
+
max_age_hours: Skip cache hits older than this many hours.
|
|
116
|
+
compact: If True, strip evidence arrays from response.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The smart endpoint response dict.
|
|
120
|
+
"""
|
|
121
|
+
timeout = TIER_TIMEOUTS.get(max_tier, PIPELINE_TIMEOUT)
|
|
122
|
+
payload: dict = {"claim": claim, "max_tier": max_tier, "compact": compact}
|
|
123
|
+
input_type = self._detect_input_type(claim)
|
|
124
|
+
if input_type:
|
|
125
|
+
payload["input_type"] = input_type
|
|
126
|
+
if max_age_hours:
|
|
127
|
+
payload["max_age_hours"] = max_age_hours
|
|
128
|
+
|
|
129
|
+
async with httpx.AsyncClient(
|
|
130
|
+
timeout=httpx.Timeout(timeout, connect=10.0)
|
|
131
|
+
) as client:
|
|
132
|
+
resp = await client.post(
|
|
133
|
+
f"{self.base_url}/api/v1/agent/check",
|
|
134
|
+
json=payload,
|
|
135
|
+
headers=self._headers(),
|
|
136
|
+
)
|
|
137
|
+
if resp.status_code == 402:
|
|
138
|
+
raise InsufficientBalanceError(
|
|
139
|
+
f"Insufficient balance for {max_tier} tier"
|
|
140
|
+
)
|
|
141
|
+
if resp.status_code not in (200, 201):
|
|
142
|
+
raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
|
|
143
|
+
return resp.json()
|
|
144
|
+
|
|
145
|
+
async def submit_with_fallback(
|
|
146
|
+
self,
|
|
147
|
+
claim: str,
|
|
148
|
+
max_tier: str = "quick",
|
|
149
|
+
max_age_hours: Optional[int] = None,
|
|
150
|
+
compact: bool = False,
|
|
151
|
+
) -> dict:
|
|
152
|
+
"""Submit a claim with tier fallback: lookup → quick → full (up to max_tier).
|
|
153
|
+
|
|
154
|
+
Prefers the smart endpoint (M-03). Falls back to client-side tier
|
|
155
|
+
escalation if the server returns 404 (backward compat with older servers).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
claim: The claim text to analyse.
|
|
159
|
+
max_tier: Maximum tier to attempt ("lookup", "quick", or "full").
|
|
160
|
+
max_age_hours: Skip cache hits older than this many hours.
|
|
161
|
+
compact: If True, strip evidence arrays from response.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The response dict from whichever tier succeeded.
|
|
165
|
+
"""
|
|
166
|
+
# Try smart endpoint first (M-03)
|
|
167
|
+
try:
|
|
168
|
+
return await self.submit_smart(
|
|
169
|
+
claim, max_tier=max_tier, max_age_hours=max_age_hours, compact=compact
|
|
170
|
+
)
|
|
171
|
+
except RuntimeError as e:
|
|
172
|
+
if "404" in str(e):
|
|
173
|
+
logger.info(
|
|
174
|
+
"Smart endpoint not available, falling back to client-side escalation"
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
# Client-side fallback for older servers
|
|
180
|
+
max_rank = TIER_ORDER.index(max_tier) if max_tier in TIER_ORDER else 1
|
|
181
|
+
|
|
182
|
+
# Always try lookup first
|
|
183
|
+
try:
|
|
184
|
+
result = await self.submit_tier(claim, "lookup", compact=compact)
|
|
185
|
+
if result.get("hit"):
|
|
186
|
+
return result
|
|
187
|
+
# Lookup miss — escalate if allowed
|
|
188
|
+
except InsufficientBalanceError:
|
|
189
|
+
raise
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning(f"Lookup failed: {e}")
|
|
192
|
+
|
|
193
|
+
# Escalate to quick if allowed
|
|
194
|
+
if max_rank >= TIER_ORDER.index("quick"):
|
|
195
|
+
try:
|
|
196
|
+
return await self.submit_tier(claim, "quick", compact=compact)
|
|
197
|
+
except InsufficientBalanceError:
|
|
198
|
+
raise
|
|
199
|
+
except Exception as e:
|
|
200
|
+
if max_rank >= TIER_ORDER.index("full"):
|
|
201
|
+
logger.warning(f"Quick failed, escalating to full: {e}")
|
|
202
|
+
else:
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
# Escalate to full if allowed
|
|
206
|
+
if max_rank >= TIER_ORDER.index("full"):
|
|
207
|
+
return await self.submit_tier(claim, "full", compact=compact)
|
|
208
|
+
|
|
209
|
+
raise RuntimeError(f"No tier up to {max_tier} returned a result")
|
|
210
|
+
|
|
211
|
+
async def submit_check_sync(self, text: str) -> dict:
|
|
212
|
+
"""Submit content via the synchronous /run endpoint and return the full result.
|
|
213
|
+
|
|
214
|
+
Backward-compatible alias. For new code, prefer submit_with_fallback().
|
|
215
|
+
"""
|
|
216
|
+
input_type = (
|
|
217
|
+
"url" if text.strip().startswith(("http://", "https://")) else "text"
|
|
218
|
+
)
|
|
219
|
+
payload = {"input_type": input_type}
|
|
220
|
+
if input_type == "url":
|
|
221
|
+
payload["url"] = text.strip()
|
|
222
|
+
else:
|
|
223
|
+
payload["content"] = text.strip()
|
|
224
|
+
|
|
225
|
+
async with httpx.AsyncClient(
|
|
226
|
+
timeout=httpx.Timeout(PIPELINE_TIMEOUT + 30.0, connect=10.0)
|
|
227
|
+
) as client:
|
|
228
|
+
resp = await client.post(
|
|
229
|
+
f"{self.base_url}/api/v1/checks/run",
|
|
230
|
+
json=payload,
|
|
231
|
+
headers=self._headers(),
|
|
232
|
+
)
|
|
233
|
+
if resp.status_code != 200:
|
|
234
|
+
raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
|
|
235
|
+
return resp.json()
|
|
236
|
+
|
|
237
|
+
async def submit_check_sse(self, text: str) -> str:
|
|
238
|
+
"""Submit content for evidence research and wait for completion.
|
|
239
|
+
|
|
240
|
+
Consumes the SSE stream from POST /stream. For URL inputs that pause
|
|
241
|
+
for claim selection, auto-selects all extracted claims (up to 5) and
|
|
242
|
+
polls until Phase 2 completes.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The completed check ID.
|
|
246
|
+
"""
|
|
247
|
+
input_type = (
|
|
248
|
+
"url" if text.strip().startswith(("http://", "https://")) else "text"
|
|
249
|
+
)
|
|
250
|
+
payload = {"input_type": input_type}
|
|
251
|
+
if input_type == "url":
|
|
252
|
+
payload["url"] = text.strip()
|
|
253
|
+
else:
|
|
254
|
+
payload["content"] = text.strip()
|
|
255
|
+
|
|
256
|
+
timeout = PIPELINE_TIMEOUT
|
|
257
|
+
check_id = None
|
|
258
|
+
awaiting_selection = False
|
|
259
|
+
claim_positions = []
|
|
260
|
+
|
|
261
|
+
async with httpx.AsyncClient(
|
|
262
|
+
timeout=httpx.Timeout(timeout, connect=10.0)
|
|
263
|
+
) as client:
|
|
264
|
+
async with client.stream(
|
|
265
|
+
"POST",
|
|
266
|
+
f"{self.base_url}/api/v1/checks/stream",
|
|
267
|
+
json=payload,
|
|
268
|
+
headers={**self._headers(), "Accept": "text/event-stream"},
|
|
269
|
+
) as response:
|
|
270
|
+
if response.status_code != 200:
|
|
271
|
+
body = await response.aread()
|
|
272
|
+
raise RuntimeError(
|
|
273
|
+
f"API error {response.status_code}: {body.decode()}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
check_id = response.headers.get("x-check-id")
|
|
277
|
+
|
|
278
|
+
async for line in response.aiter_lines():
|
|
279
|
+
line = line.strip()
|
|
280
|
+
if not line or not line.startswith("data:"):
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
data = json.loads(line[5:].strip())
|
|
284
|
+
except json.JSONDecodeError:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
event_type = data.get("type")
|
|
288
|
+
|
|
289
|
+
if event_type == "completed":
|
|
290
|
+
check_id = check_id or data.get("checkId")
|
|
291
|
+
return check_id
|
|
292
|
+
|
|
293
|
+
elif event_type == "error":
|
|
294
|
+
raise RuntimeError(
|
|
295
|
+
f"Pipeline error: {data.get('error', 'unknown')}"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
elif event_type == "timeout":
|
|
299
|
+
raise RuntimeError("Pipeline timed out")
|
|
300
|
+
|
|
301
|
+
elif event_type == "awaiting_selection":
|
|
302
|
+
check_id = check_id or data.get("checkId")
|
|
303
|
+
# Extract claim positions, preferring highest-ranked
|
|
304
|
+
claims = data.get("claims", [])
|
|
305
|
+
claims_sorted = sorted(
|
|
306
|
+
claims,
|
|
307
|
+
key=lambda c: c.get("significance_rank", 999),
|
|
308
|
+
)
|
|
309
|
+
claim_positions = [
|
|
310
|
+
c.get("position", i) for i, c in enumerate(claims_sorted)
|
|
311
|
+
]
|
|
312
|
+
awaiting_selection = True
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
elif event_type == "connected":
|
|
316
|
+
check_id = check_id or data.get("checkId")
|
|
317
|
+
|
|
318
|
+
if not check_id:
|
|
319
|
+
raise RuntimeError("No check ID received from pipeline")
|
|
320
|
+
|
|
321
|
+
if awaiting_selection:
|
|
322
|
+
await self._select_claims(check_id, claim_positions[:5])
|
|
323
|
+
await self._poll_until_complete(check_id)
|
|
324
|
+
|
|
325
|
+
return check_id
|
|
326
|
+
|
|
327
|
+
async def _select_claims(self, check_id: str, positions: list[int]) -> None:
|
|
328
|
+
"""Select claims for Phase 2 analysis."""
|
|
329
|
+
if not positions:
|
|
330
|
+
positions = [0]
|
|
331
|
+
|
|
332
|
+
async with httpx.AsyncClient(timeout=GET_TIMEOUT) as client:
|
|
333
|
+
resp = await client.patch(
|
|
334
|
+
f"{self.base_url}/api/v1/checks/{check_id}/select-claims",
|
|
335
|
+
json={"selected_positions": positions},
|
|
336
|
+
headers={**self._headers(), "Content-Type": "application/json"},
|
|
337
|
+
)
|
|
338
|
+
if resp.status_code not in (200, 202):
|
|
339
|
+
raise RuntimeError(
|
|
340
|
+
f"Claim selection failed ({resp.status_code}): {resp.text}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
logger.info(f"Auto-selected {len(positions)} claims for check {check_id}")
|
|
344
|
+
|
|
345
|
+
async def _poll_until_complete(self, check_id: str) -> None:
|
|
346
|
+
"""Poll check status until completed or failed."""
|
|
347
|
+
elapsed = 0.0
|
|
348
|
+
async with httpx.AsyncClient(timeout=GET_TIMEOUT) as client:
|
|
349
|
+
while elapsed < MAX_POLL_DURATION:
|
|
350
|
+
resp = await client.get(
|
|
351
|
+
f"{self.base_url}/api/v1/checks/{check_id}",
|
|
352
|
+
headers=self._headers(),
|
|
353
|
+
)
|
|
354
|
+
if resp.status_code == 200:
|
|
355
|
+
status = resp.json().get("status")
|
|
356
|
+
if status == "completed":
|
|
357
|
+
return
|
|
358
|
+
elif status == "failed":
|
|
359
|
+
error = resp.json().get("errorMessage", "Pipeline failed")
|
|
360
|
+
raise RuntimeError(f"Pipeline failed: {error}")
|
|
361
|
+
|
|
362
|
+
await asyncio.sleep(POLL_INTERVAL)
|
|
363
|
+
elapsed += POLL_INTERVAL
|
|
364
|
+
|
|
365
|
+
raise RuntimeError(f"Pipeline did not complete within {MAX_POLL_DURATION}s")
|
|
366
|
+
|
|
367
|
+
async def get_check(self, check_id: str, computed: bool = False) -> dict:
|
|
368
|
+
"""Retrieve a completed check by ID.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
check_id: UUID of the check.
|
|
372
|
+
computed: If True, include _computed analytics block.
|
|
373
|
+
"""
|
|
374
|
+
params = {"computed": "true"} if computed else {}
|
|
375
|
+
async with httpx.AsyncClient(timeout=GET_TIMEOUT) as client:
|
|
376
|
+
resp = await client.get(
|
|
377
|
+
f"{self.base_url}/api/v1/checks/{check_id}",
|
|
378
|
+
headers=self._headers(),
|
|
379
|
+
params=params,
|
|
380
|
+
)
|
|
381
|
+
if resp.status_code != 200:
|
|
382
|
+
raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
|
|
383
|
+
return resp.json()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class InsufficientBalanceError(RuntimeError):
|
|
387
|
+
"""Raised when the agent's credit balance is insufficient for the requested tier."""
|
|
388
|
+
|
|
389
|
+
pass
|