Ports the GraphMCP-Example substrate into lore-engine-poc: - 8 Go workers under workers/ (discord-connector, discord-filter, lore-watcher, ingestion-worker, entity-extractor, lore-extractor, encounter-processor, mcp-server), each with Dockerfile + go.mod - 3 Go unit-test files (encounter-processor, ingestion-worker, lore-extractor) — other 5 workers rely on integration tests via the live stack - plugins/nsc.py: thin httpx proxy from gateway to lore-mcp-server:9000, exposes all 11 inherited GraphMCP tools (input schemas verbatim from mcp-server/main.go) - docker-compose.yml: adds lore-redis + lore-mcp-server + the 7 worker services (lore- prefix to avoid clash with other GraphMCP stacks) - verify-merge.sh (171 LOC, 7 pass conditions) + docs/VERIFICATION.md - tests/contract/test_graphmcp_tool_contracts.py (15 tests; skipped when stack is down — TDD pattern, becomes active once docker compose up brings the stack) - README.md + test.sh updated for the merged service inventory Leader notes (2026-06-27 03:50): - Worker self-blocked review-required after 2 runs (run #7 hit 120/120 iteration budget; run #8 staged 40 files and reported shippable). - Tests are SKIPPED until docker compose up — worker chose that pattern over mocking (consistent with the lore-engine-poc project convention). To activate, run `docker compose up -d --build && pytest tests/contract/`. - File Scope reconciliation: story said gateway/plugins/nsc/__init__.py; worker shipped plugins/nsc.py (flat file). Justified by the existing plugins/ convention in lore-engine-poc (server.py glob("*.py")). A future PR could split nsc into a package once server.py learns __init__.py discovery. - nsc plugin exposes 11 tools (not 8) — the AC said "8" but the worker enumerated all 11 tools present in mcp-server/main.go. The encounter-specific 3 tools (list_encounters, search_encounters, get_encounter) were included for consistency. Story AC #2 reads "≥ 8 GraphMCP tools" so this exceeds AC. Refs: S2-phase-1-substrate-merge, milestone #64 P1 — Substrate merge
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""
|
||
nsc plugin — thin httpx proxy to the Go mcp-server.
|
||
|
||
The lore-engine-poc gateway exposes MCP tools by importing Python plugin
|
||
files from /app/plugins. The 11 GraphMCP-Example tools (semantic_search,
|
||
graph_traverse, get_context, get_person_profile, query_as_npc,
|
||
log_encounter, get_unresolved, get_contradictions, list_encounters,
|
||
search_encounters, get_encounter) live in a Go service (`lore-mcp-server`
|
||
on port 9000) and speak JSON-RPC over HTTP at `/mcp`.
|
||
|
||
Per the Phase 1 ambiguity ("workers stay Go, gateway stays Python"):
|
||
this plugin is a thin proxy that translates the gateway's JSON-RPC
|
||
into the upstream call and surfaces the tools via the gateway's
|
||
`tools/list`. The handler dispatches `tools/call` to the upstream
|
||
mcp-server and returns the parsed result.
|
||
|
||
Configuration (env):
|
||
NSC_MCP_URL — default `http://mcp-server:9000`
|
||
|
||
Note on plugin shape: lore-engine-poc's existing plugins live as flat
|
||
files in /app/plugins (see server.py load_plugins glob("*.py")). The
|
||
nsc plugin follows that convention — one file, one register() entry
|
||
point. A future enhancement (out of Phase 1 scope) could split nsc
|
||
into a package directory once server.py learns to discover __init__.py.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from server import REGISTRY
|
||
|
||
# The 11 inherited GraphMCP tools. Input schemas copied verbatim from
|
||
# /root/GraphMCP-Example/services/mcp-server/main.go lines 137–268.
|
||
# If the upstream adds or removes a tool, this list is the contract
|
||
# enforced by tests/contract/test_graphmcp_tool_contracts.py.
|
||
GRAPHMCP_TOOLS: list[dict[str, Any]] = [
|
||
{
|
||
"name": "semantic_search",
|
||
"description": (
|
||
"Find messages and chunks semantically similar to a query "
|
||
"using vector similarity over the knowledge graph"
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "Natural language search query"},
|
||
"limit": {"type": "integer", "description": "Max results to return (default 5)"},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
{
|
||
"name": "graph_traverse",
|
||
"description": (
|
||
"Traverse the knowledge graph from a named entity to find "
|
||
"related entities and messages"
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"entity": {"type": "string", "description": "Entity name to start traversal from"},
|
||
"depth": {"type": "integer", "description": "Traversal depth 1-3 (default 2)"},
|
||
},
|
||
"required": ["entity"],
|
||
},
|
||
},
|
||
{
|
||
"name": "get_context",
|
||
"description": (
|
||
"Get full context for a specific message including its "
|
||
"chunks and all related entities"
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"message_id": {"type": "string", "description": "Message ID to retrieve context for"},
|
||
},
|
||
"required": ["message_id"],
|
||
},
|
||
},
|
||
{
|
||
"name": "get_person_profile",
|
||
"description": (
|
||
"Get topics, interests, and message history associated "
|
||
"with a named person"
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string", "description": "Person's name"},
|
||
},
|
||
"required": ["name"],
|
||
},
|
||
},
|
||
{
|
||
"name": "query_as_npc",
|
||
"description": (
|
||
"Query the knowledge graph from a specific NPC's "
|
||
"perspective, scoped to only what they have personally "
|
||
"witnessed. Returns semantic search results and encounter "
|
||
"graph context filtered to the NPC's knowledge horizon."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"npc_name": {"type": "string", "description": "The NPC's name (must match a Person node)"},
|
||
"question": {"type": "string", "description": "The question the NPC is trying to answer"},
|
||
"limit": {"type": "integer", "description": "Max chunk results (default 5)"},
|
||
},
|
||
"required": ["npc_name", "question"],
|
||
},
|
||
},
|
||
{
|
||
"name": "log_encounter",
|
||
"description": (
|
||
"Log a D&D encounter directly to the knowledge graph. "
|
||
"Creates an Encounter node with WITNESSED edges for each "
|
||
"participant. Call this after each NPC conversation so "
|
||
"the NPC remembers it next time."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {"type": "string", "description": "Short title for the encounter"},
|
||
"participants": {"type": "string", "description": "Comma-separated list of participant names"},
|
||
"summary": {"type": "string", "description": "Brief summary of what happened or was discussed"},
|
||
"location": {"type": "string", "description": "Location name (optional)"},
|
||
"type": {"type": "string", "description": "Encounter type: conversation, combat, discovery (default: conversation)"},
|
||
},
|
||
"required": ["title", "participants", "summary"],
|
||
},
|
||
},
|
||
{
|
||
"name": "get_unresolved",
|
||
"description": (
|
||
"List provisional entity nodes (lore_verified=false) — "
|
||
"entities created from encounter data that have no matching "
|
||
"lore document yet. Use this to identify gaps in the lore."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"type": {"type": "string", "description": "Filter by entity type. Omit for all."},
|
||
"limit": {"type": "integer", "description": "Max results (default 30)"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "get_contradictions",
|
||
"description": (
|
||
"Return flagged contradictions — cases where two source "
|
||
"documents make conflicting claims about the same entity."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"subject": {"type": "string", "description": "Optional entity name to filter by. Omit for all."},
|
||
"limit": {"type": "integer", "description": "Max results to return (default 20)"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "list_encounters",
|
||
"description": (
|
||
"List all past encounters stored in the campaign knowledge "
|
||
"graph, ordered by recency"
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"limit": {"type": "integer", "description": "Max encounters to return (default 10)"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "search_encounters",
|
||
"description": (
|
||
"Search and filter past encounters by keyword, location, "
|
||
"or participant name"
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "Optional keyword search in titles/summaries"},
|
||
"location": {"type": "string", "description": "Optional location name to filter by"},
|
||
"participant": {"type": "string", "description": "Optional participant name to filter by"},
|
||
"limit": {"type": "integer", "description": "Max results to return (default 10)"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "get_encounter",
|
||
"description": (
|
||
"Get complete details for a single campaign encounter by "
|
||
"ID, including participants and featured entities"
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"id": {"type": "string", "description": "The encounter ID"},
|
||
},
|
||
"required": ["id"],
|
||
},
|
||
},
|
||
]
|
||
|
||
MCP_URL = os.environ.get("NSC_MCP_URL", "http://mcp-server:9000").rstrip("/")
|
||
MCP_RPC_URL = f"{MCP_URL}/mcp"
|
||
|
||
|
||
def _call_upstream(tool_name: str, arguments: dict) -> Any:
|
||
"""Forward a tools/call to the upstream Go mcp-server and return its
|
||
parsed result content. The upstream wraps results as
|
||
{content: [{type, text}], isError}; we unwrap the text payload and
|
||
parse JSON if possible."""
|
||
payload = {
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "tools/call",
|
||
"params": {"name": tool_name, "arguments": arguments or {}},
|
||
}
|
||
try:
|
||
resp = httpx.post(MCP_RPC_URL, json=payload, timeout=30.0)
|
||
except httpx.RequestError as exc:
|
||
raise RuntimeError(
|
||
f"nsc: upstream mcp-server unreachable at {MCP_RPC_URL}: {exc}"
|
||
) from exc
|
||
if resp.status_code >= 500:
|
||
raise RuntimeError(
|
||
f"nsc: upstream mcp-server returned {resp.status_code}: {resp.text[:200]}"
|
||
)
|
||
body = resp.json()
|
||
if "error" in body and "result" not in body:
|
||
raise RuntimeError(
|
||
f"nsc: upstream mcp-server returned JSON-RPC error: {body['error']}"
|
||
)
|
||
result = body.get("result", {})
|
||
content = result.get("content", [])
|
||
if content and isinstance(content, list):
|
||
first = content[0]
|
||
if isinstance(first, dict) and "text" in first:
|
||
text = first["text"]
|
||
try:
|
||
return json.loads(text)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return text
|
||
return result
|
||
|
||
|
||
def register(registry) -> None:
|
||
"""Register the 11 GraphMCP tools with the gateway registry.
|
||
|
||
The gateway's @REGISTRY.tool decorator wraps a Python handler. We
|
||
need a closure per tool so each name is dispatched individually;
|
||
sharing one handler would still work but loses per-tool name binding
|
||
in error messages.
|
||
"""
|
||
for tool in GRAPHMCP_TOOLS:
|
||
# Bind tool_name in the closure.
|
||
tool_name = tool["name"]
|
||
|
||
def make_handler(tn: str):
|
||
def handler(args: dict) -> Any:
|
||
return _call_upstream(tn, args)
|
||
return handler
|
||
|
||
registry.tool(
|
||
name=tool["name"],
|
||
description=tool["description"],
|
||
input_schema=tool["input_schema"],
|
||
)(make_handler(tool_name))
|
||
|
||
|
||
# ── Convenience: the nsc plugin also exposes a single meta-tool that
|
||
# returns the list of GraphMCP tools it surfaces. This is a Phase-1
|
||
# debugging affordance; LLM clients can call it to verify the proxy
|
||
# is wired up.
|
||
@REGISTRY.tool(
|
||
name="nsc_tools",
|
||
description=(
|
||
"List the GraphMCP-Example MCP tools exposed by the nsc plugin "
|
||
"(Phase 1 inventory). Returns the canonical 11-tool set with "
|
||
"their input schemas."
|
||
),
|
||
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
||
)
|
||
def nsc_tools(args: dict) -> dict:
|
||
return {"tools": GRAPHMCP_TOOLS, "count": len(GRAPHMCP_TOOLS), "upstream": MCP_RPC_URL} |