Files
lore-engine-poc/plugins/nsc.py
Hermes adbb6f0cce feat(substrate): Phase 1 merge — Redis + 8 Go workers + nsc plugin
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
2026-06-27 03:48:54 +00:00

292 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 137268.
# 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}