factumstack-mcp 0.1.0__py3-none-any.whl
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.
- factumstack_mcp/__init__.py +2 -0
- factumstack_mcp/bridge.py +197 -0
- factumstack_mcp-0.1.0.dist-info/METADATA +8 -0
- factumstack_mcp-0.1.0.dist-info/RECORD +7 -0
- factumstack_mcp-0.1.0.dist-info/WHEEL +5 -0
- factumstack_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- factumstack_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# factumstack_mcp/main.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import httpx
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from mcp.server import Server
|
|
9
|
+
import mcp.types as types
|
|
10
|
+
from mcp.server.stdio import stdio_server
|
|
11
|
+
|
|
12
|
+
# LOGGING CONFIGURATION (Glass Box)
|
|
13
|
+
# Redirect logs to stderr to keep stdio transport (stdout) clean
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level=logging.INFO,
|
|
16
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
17
|
+
stream=sys.stderr
|
|
18
|
+
)
|
|
19
|
+
logger = logging.getLogger("factumstack-mcp")
|
|
20
|
+
|
|
21
|
+
# PRODUCTION CONSTANTS
|
|
22
|
+
DEFAULT_REMOTE_URL = "https://factumstack-api-131666191475.europe-west1.run.app/api/v1/audit/claim"
|
|
23
|
+
|
|
24
|
+
server = Server("factumstack-bridge")
|
|
25
|
+
|
|
26
|
+
@server.list_tools()
|
|
27
|
+
async def handle_list_tools() -> list[types.Tool]:
|
|
28
|
+
"""Lists available delegated tools from FactumStack backend."""
|
|
29
|
+
return [
|
|
30
|
+
types.Tool(
|
|
31
|
+
name="check_claim",
|
|
32
|
+
description="Audit a scientific claim using FactumStack (Scholar + OpenAlex).",
|
|
33
|
+
inputSchema={
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"claim": {"type": "string", "description": "The scientific claim to audit."},
|
|
37
|
+
"max_rigor": {"type": "boolean", "default": False, "description": "Deep dual-swarm search (Plan Developer+)."},
|
|
38
|
+
},
|
|
39
|
+
"required": ["claim"],
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
types.Tool(
|
|
43
|
+
name="get_account_status",
|
|
44
|
+
description="Consult your current plan, capabilities and quotas in FactumStack.",
|
|
45
|
+
inputSchema={"type": "object", "properties": {}},
|
|
46
|
+
),
|
|
47
|
+
types.Tool(
|
|
48
|
+
name="check_connection",
|
|
49
|
+
description="Validate connectivity and latency with the FactumStack Cloud engine.",
|
|
50
|
+
inputSchema={"type": "object", "properties": {}},
|
|
51
|
+
)
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
@server.call_tool()
|
|
55
|
+
async def handle_call_tool(name: str, arguments: dict | None) -> list[types.TextContent]:
|
|
56
|
+
"""
|
|
57
|
+
Professional bridge to the Cloud Run backend.
|
|
58
|
+
Delegates tool logic to the API endpoints.
|
|
59
|
+
"""
|
|
60
|
+
api_key = os.getenv("FACTUMSTACK_API_KEY")
|
|
61
|
+
full_url = os.getenv("FACTUMSTACK_REMOTE_URL", DEFAULT_REMOTE_URL)
|
|
62
|
+
# Extract base URL to navigate between endpoints (/audit vs /auth)
|
|
63
|
+
base_url = full_url.split("/audit/claim")[0]
|
|
64
|
+
|
|
65
|
+
if not api_key:
|
|
66
|
+
logger.critical("FACTUMSTACK_API_KEY not found in environment.")
|
|
67
|
+
return [types.TextContent(type="text", text=json.dumps({"error": "FACTUMSTACK_API_KEY not configured."}))]
|
|
68
|
+
|
|
69
|
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
70
|
+
try:
|
|
71
|
+
if name == "check_claim":
|
|
72
|
+
claim = arguments.get("claim")
|
|
73
|
+
max_rigor = arguments.get("max_rigor", False)
|
|
74
|
+
logger.info(f"Starting audit: '{claim}' (Rigor: {max_rigor})")
|
|
75
|
+
response = await client.post(
|
|
76
|
+
f"{base_url}/audit/claim",
|
|
77
|
+
json={"claim": claim, "max_rigor": max_rigor},
|
|
78
|
+
headers={"Authorization": f"Bearer {api_key}"}
|
|
79
|
+
)
|
|
80
|
+
elif name == "get_account_status":
|
|
81
|
+
logger.info("Checking account status...")
|
|
82
|
+
response = await client.get(
|
|
83
|
+
f"{base_url}/auth/profile",
|
|
84
|
+
headers={"Authorization": f"Bearer {api_key}"}
|
|
85
|
+
)
|
|
86
|
+
elif name == "check_connection":
|
|
87
|
+
import time
|
|
88
|
+
start_time = time.perf_counter()
|
|
89
|
+
response = await client.get(f"{base_url}/auth/profile", headers={"Authorization": f"Bearer {api_key}"})
|
|
90
|
+
latency = (time.perf_counter() - start_time) * 1000
|
|
91
|
+
|
|
92
|
+
profile_data = response.json()
|
|
93
|
+
conn_data = {
|
|
94
|
+
"status": "connected",
|
|
95
|
+
"latency_ms": round(latency, 2),
|
|
96
|
+
"endpoint": base_url,
|
|
97
|
+
"providers": profile_data.get("system_health", {})
|
|
98
|
+
}
|
|
99
|
+
return [types.TextContent(type="text", text=json.dumps(conn_data, indent=2))]
|
|
100
|
+
else:
|
|
101
|
+
return [types.TextContent(type="text", text=json.dumps({"error": f"Unknown tool: {name}"}))]
|
|
102
|
+
|
|
103
|
+
# Unified API Error Handling (Zero-Trust)
|
|
104
|
+
if response.status_code == 401:
|
|
105
|
+
return [types.TextContent(type="text", text=json.dumps({"error": "Invalid or expired API Key."}))]
|
|
106
|
+
|
|
107
|
+
if response.status_code == 403:
|
|
108
|
+
return [types.TextContent(type="text", text=json.dumps({"error": "Plan does not support this feature (e.g., max_rigor=True)."}))]
|
|
109
|
+
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
data = response.json()
|
|
112
|
+
|
|
113
|
+
# PROFESSIONAL JSON Formatting
|
|
114
|
+
if name == "check_claim":
|
|
115
|
+
audit = data.get("epistemological_audit", {})
|
|
116
|
+
flaws = audit.get("methodological_flaws", [])
|
|
117
|
+
fallacies = audit.get("fallacies_detected", [])
|
|
118
|
+
|
|
119
|
+
mcp_data = {
|
|
120
|
+
"factum_score": data.get("factum_score"),
|
|
121
|
+
"factum_rating": data.get("factum_rating"),
|
|
122
|
+
"evidence_level": audit.get("evidence_level_found", "N/A"),
|
|
123
|
+
"summary": data.get("summary"),
|
|
124
|
+
"critical_flaw": (flaws + fallacies)[0] if (flaws or fallacies) else "None"
|
|
125
|
+
}
|
|
126
|
+
logger.info(f"Audit completed. Score: {mcp_data['factum_score']}")
|
|
127
|
+
return [types.TextContent(type="text", text=json.dumps(mcp_data, indent=2))]
|
|
128
|
+
|
|
129
|
+
if name == "get_account_status":
|
|
130
|
+
from datetime import datetime
|
|
131
|
+
import time
|
|
132
|
+
q = data.get("quotas", {})
|
|
133
|
+
ident = data.get("identity", {})
|
|
134
|
+
|
|
135
|
+
reset_ms = q.get('ratelimit_reset_ms')
|
|
136
|
+
time_to_reset = max(0, int((reset_ms - (time.time() * 1000)) / 1000)) if reset_ms is not None else None
|
|
137
|
+
exp = data.get("expires_at")
|
|
138
|
+
|
|
139
|
+
account_data = {
|
|
140
|
+
"identity": {
|
|
141
|
+
"client_id": ident.get("client_id"),
|
|
142
|
+
"plan": ident.get("plan", "basic").upper(),
|
|
143
|
+
"roles": ident.get("roles", []),
|
|
144
|
+
"permissions": ident.get("permissions", [])
|
|
145
|
+
},
|
|
146
|
+
"quotas": {
|
|
147
|
+
"credits_remaining": q.get("credits_remaining"),
|
|
148
|
+
"ratelimit_limit": q.get("ratelimit_limit"),
|
|
149
|
+
"ratelimit_remaining": q.get("ratelimit_remaining"),
|
|
150
|
+
"ratelimit_reset_seconds": time_to_reset
|
|
151
|
+
},
|
|
152
|
+
"features": data.get("features", []),
|
|
153
|
+
"expires_at_iso": datetime.fromtimestamp(exp/1000).isoformat() if exp else None,
|
|
154
|
+
"metadata": data.get("metadata", {})
|
|
155
|
+
}
|
|
156
|
+
return [types.TextContent(type="text", text=json.dumps(account_data, indent=2))]
|
|
157
|
+
|
|
158
|
+
except httpx.TimeoutException:
|
|
159
|
+
logger.error(f"Timeout in tool {name}")
|
|
160
|
+
return [types.TextContent(type="text", text=json.dumps({"error": "The server took too long to respond."}))]
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.exception(f"Unexpected error in {name}")
|
|
163
|
+
return [types.TextContent(type="text", text=json.dumps({"error": f"Bridge error: {str(e)}"}))]
|
|
164
|
+
|
|
165
|
+
async def start_server():
|
|
166
|
+
"""Startup the stdio transport server."""
|
|
167
|
+
logger.info("FactumStack MCP Bridge started (Transport: stdio)")
|
|
168
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
169
|
+
await server.run(
|
|
170
|
+
read_stream,
|
|
171
|
+
write_stream,
|
|
172
|
+
server.create_initialization_options()
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def run():
|
|
176
|
+
"""Entry point for the factumstack-mcp command."""
|
|
177
|
+
# Load environment variables from .env if available
|
|
178
|
+
try:
|
|
179
|
+
from dotenv import load_dotenv
|
|
180
|
+
load_dotenv()
|
|
181
|
+
logger.info(".env file loaded successfully.")
|
|
182
|
+
except ImportError:
|
|
183
|
+
logger.warning("python-dotenv not installed, skipping .env load.")
|
|
184
|
+
|
|
185
|
+
if not os.getenv("FACTUMSTACK_API_KEY"):
|
|
186
|
+
print("CRITICAL: FACTUMSTACK_API_KEY not found.", file=sys.stderr)
|
|
187
|
+
print("Usage: export FACTUMSTACK_API_KEY='your_key' or set it in a .env file", file=sys.stderr)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
asyncio.run(start_server())
|
|
192
|
+
except KeyboardInterrupt:
|
|
193
|
+
logger.info("Server stopped by user")
|
|
194
|
+
sys.exit(0)
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
run()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
factumstack_mcp/__init__.py,sha256=R4VlPzeGB3NYA_sR99Pmxv1NwFfgbS0VNenXMIARBEQ,73
|
|
2
|
+
factumstack_mcp/bridge.py,sha256=ldbsmZwQ2r7t4wW-h0usUA9B7t_31Wmcpxo-u5Dpcfw,8755
|
|
3
|
+
factumstack_mcp-0.1.0.dist-info/METADATA,sha256=2tgoL6fjImPritey7-0ExhT4LFDKanzd9MfI9GSjWi4,238
|
|
4
|
+
factumstack_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
factumstack_mcp-0.1.0.dist-info/entry_points.txt,sha256=s0xJFfLQpTVPpDe5URqXD6bUGDH9nE0u7ENJ3CMLmCc,63
|
|
6
|
+
factumstack_mcp-0.1.0.dist-info/top_level.txt,sha256=tGwkMURdubDiVftJTg6m-cIEVcbmJocD9TY39kD-D5U,16
|
|
7
|
+
factumstack_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
factumstack_mcp
|