MCP Protocol Reference¶
The ZettelForge MCP server implements the Model Context Protocol specification (protocol version 2024-11-05) over stdio transport using JSON-RPC 2.0.
Protocol overview¶
| Property | Value |
|---|---|
| Transport | stdio (stdin for requests, stdout for responses) |
| Protocol | JSON-RPC 2.0 |
| MCP version | 2024-11-05 |
| Server name | zettelforge |
| Tools available | 7 |
| Version reported | 2.7.0 |
| Lazy initialization | MemoryManager is instantiated on first tool call, not on import |
Lifecycle¶
The MCP lifecycle has three phases:
client server
| |
|--- initialize --------------->| Phase 1: Initialization
|<-- initialize result ---------|
|--- notifications/initialized->| (no response)
| |
|--- tools/list --------------->| Phase 2: Tool discovery
|<-- tools list ----------------|
| |
|--- tools/call --------------->| Phase 3: Tool execution
|<-- tool result ---------------|
| |
|--- tools/call --------------->|
|<-- tool result ---------------|
Lazy singleton contract¶
Importing zettelforge.mcp (or zettelforge.mcp.server) does not instantiate MemoryManager. The initialize and tools/list methods work without touching the backend. MemoryManager is created on the first tools/call that reaches handle_tool_call(). Tool introspection is therefore side-effect-free.
from zettelforge.mcp import TOOLS, run_stdio
# TOOLS is a static list — no backend started
assert len(TOOLS) == 7
# run_stdio reads stdin, processes requests, writes stdout
run_stdio()
TLP access control¶
Every tool that reads from or writes to memory enforces TLP (Traffic Light Protocol) access control. The actor parameter identifies the caller:
- No actor (default): unauthenticated calls are capped at
TLP:GREEN. Notes markedTLP:AMBER,TLP:AMBER+STRICT, orTLP:REDare filtered from results. - Authenticated actor: a caller matching the configured single-user identity receives results up to
TLP:RED. - TLP override: callers may request results above their sharing ceiling by providing a non-empty
tlp_override_reason. The override is audited to a JSONL log.
The actor value is resolved from these argument keys in order: actor, caller, identity, user.
Default TLP for new notes is TLP:CLEAR.
Tool schemas¶
zettelforge_remember¶
Store threat intelligence in memory. Extracts entities (actors, CVEs, tools, campaigns) and populates the knowledge graph. With evolve=true (default), uses an LLM to compare against existing notes and decides whether to add, update, or supersede.
Input schema:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
content |
string | yes | — | Threat intelligence text to store |
domain |
string | no | "cti" |
Domain: cti, incident, general |
tlp |
string | no | "TLP:CLEAR" |
TLP marking for the note |
actor |
string | no | — | Caller identity for audit attribution |
source |
string | no | "mcp" |
Source reference string |
evolve |
boolean | no | true |
Enable memory evolution (LLM-based dedup/merge) |
TLP enum values: TLP:CLEAR, TLP:WHITE, TLP:GREEN, TLP:AMBER, TLP:AMBER+STRICT, TLP:RED (and short aliases without the TLP: prefix).
Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "zettelforge_remember",
"arguments": {
"content": "APT28 used CVE-2024-3094 against NATO networks.",
"domain": "cti",
"source": "report-2026-001",
"tlp": "TLP:AMBER",
"evolve": true
}
}
}
Response fields:
| Field | Type | Description |
|---|---|---|
note_id |
string or null | ID of the created/updated note, or null on error |
status |
string | "created", "updated", "corrected", "noop" |
entities |
string[] | Up to 10 extracted entity values |
tlp |
string | TLP marking applied to the stored note |
zettelforge_recall¶
Search memory using blended vector + graph retrieval. Returns ranked results with entities, confidence scores, tier metadata, and TLP labels.
Input schema:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
query |
string | yes | — | Natural language search query |
k |
integer | no | 10 |
Maximum number of results |
domain |
string | no | — | Optional domain filter |
actor |
string | no | — | Caller identity; unauthenticated calls are capped at TLP:GREEN |
tlp_override_reason |
string | no | — | Audited reason to return content above the caller's sharing ceiling |
Request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "zettelforge_recall",
"arguments": {
"query": "What tools does APT28 use?",
"k": 10,
"domain": "cti"
}
}
}
Response fields:
| Field | Type | Description |
|---|---|---|
results |
object[] | Ranked search results |
results[].id |
string | Note ID |
results[].content |
string | First 500 characters of note content, wrapped in BEGIN_UNTRUSTED_CONTENT … END_UNTRUSTED_CONTENT markers |
results[].content_is_untrusted |
boolean | Always true; content has passed prompt-injection screening but must be treated as untrusted |
results[].context |
string | Clean semantic context string (content without the UNTRUSTED wrapper, safe to display) |
results[].entities |
string[] | Up to 10 extracted entities |
results[].tier |
string | Epistemic tier: "A" (authoritative), "B" (operational, default), "C" (support) |
results[].tlp |
string | TLP marking of the note |
results[].confidence |
number | Confidence score (0.0 to 1.0) |
count |
integer | Number of results returned |
latency_ms |
integer | Query latency in milliseconds |
tlp_share_max |
string | Effective TLP ceiling applied to this response |
tlp_override |
boolean | Whether a TLP override was applied |
zettelforge_synthesize¶
Generate a synthesized answer from ZettelForge memories using RAG (Retrieval-Augmented Generation). Requires a configured LLM provider; without one, returns a well-formed response with answer: "No specific answer found" and confidence: 0.0.
Input schema:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
query |
string | yes | — | Question to answer from memory |
format |
string | no | "direct_answer" |
Output format (see below) |
actor |
string | no | — | Caller identity; unauthenticated calls are capped at TLP:GREEN |
tlp_override_reason |
string | no | — | Audited reason to use sources above the caller's sharing ceiling |
Format values:
| Value | Description |
|---|---|
direct_answer |
Concise answer with source attribution. Works in OSS with a configured LLM. |
synthesized_brief |
Structured intelligence brief. Available via ThreatRecall.ai SaaS; falls back to direct_answer in OSS. |
timeline_analysis |
Chronological event sequence. Available via ThreatRecall.ai SaaS; falls back to direct_answer in OSS. |
relationship_map |
Entity relationship summary. Available via ThreatRecall.ai SaaS; falls back to direct_answer in OSS. |
Request:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "zettelforge_synthesize",
"arguments": {
"query": "What is APT28's targeting pattern in 2026?",
"format": "direct_answer"
}
}
}
Response fields:
| Field | Type | Description |
|---|---|---|
synthesis |
object | The generated answer |
synthesis.answer |
string | The synthesized answer text; "No specific answer found" when no LLM is configured |
synthesis.confidence |
number | Answer confidence (0.0 to 1.0); 0.0 when no LLM is configured |
synthesis.sources |
string[] | Note IDs cited by the LLM in its answer; empty when no LLM is configured |
sources_count |
integer | Number of memory notes retrieved as candidates (non-zero even without LLM) |
sources |
object[] | Retrieved candidate notes (see fields below) |
sources[].note_id |
string | Note ID |
sources[].relevance_score |
number | Retrieval relevance score (0.0 to 1.0) |
sources[].quote |
string | First characters of note content, wrapped in BEGIN_UNTRUSTED_CONTENT … END_UNTRUSTED_CONTENT |
sources[].tier |
string | Epistemic tier: "A" (authoritative), "B" (operational), "C" (support) |
sources[].tlp |
string | TLP marking of the source note |
tlp_max |
string | Highest TLP level seen among sources |
tlp_share_max |
string | Effective TLP ceiling applied |
tlp_override |
boolean | Whether a TLP override was applied |
zettelforge_entity¶
Fast entity lookup by type. Uses an O(1) index for direct entity-to-note mapping.
Input schema:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
type |
string | yes | — | Entity type (see common types below) |
value |
string | yes | — | Entity value (e.g. "apt28", "CVE-2024-3094") |
k |
integer | no | 5 |
Maximum results |
actor |
string | no | — | Caller identity; unauthenticated calls are capped at TLP:GREEN |
tlp_override_reason |
string | no | — | Audited reason to return content above the caller's sharing ceiling |
Common entity types:
| Type | Indexing notes |
|---|---|
intrusion_set |
Use for APT groups and named threat actors (e.g. "apt28", "lazarus"). This is the primary type for nation-state actors. |
cve |
CVE identifiers (e.g. "CVE-2024-3094") |
tool |
Malware and attack tooling names |
campaign |
Named campaigns |
attack_pattern |
MITRE ATT&CK technique identifiers and descriptions |
actor |
Generic actor references not matched to a named intrusion set |
person |
Individual names |
domain |
Domain names (175+ unique in a typical deployment) |
url |
URLs (434+ unique in a typical deployment) |
ipv4 |
IPv4 addresses |
md5, sha1, sha256 |
Indicator hashes |
email |
Email addresses |
location |
Geographic locations |
organization |
Organization names |
The entity indexer (entity_indexer.py) assigns APT-numbered groups (e.g. APT28, APT29) to intrusion_set, not actor. Pass the normalized lowercase form as value (e.g. "apt28", not "APT28").
Request:
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "zettelforge_entity",
"arguments": {
"type": "cve",
"value": "CVE-2024-3094",
"k": 5
}
}
}
Response fields:
| Field | Type | Description |
|---|---|---|
results |
object[] | Notes referencing this entity |
results[].id |
string | Note ID |
results[].content |
string | First 300 characters of note content, wrapped in BEGIN_UNTRUSTED_CONTENT … END_UNTRUSTED_CONTENT |
results[].content_is_untrusted |
boolean | Always true; treat as untrusted |
results[].tier |
string | Epistemic tier: "A" (authoritative), "B" (operational), "C" (support) |
results[].tlp |
string | TLP marking of the note |
count |
integer | Number of results |
tlp_share_max |
string | Effective TLP ceiling applied |
tlp_override |
boolean | Whether a TLP override was applied |
zettelforge_graph¶
Traverse the STIX 2.1 knowledge graph starting from a given entity. Shows relationships such as uses, targets, attributed-to. Returns up to 20 paths.
Input schema:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
type |
string | yes | — | Starting entity type |
value |
string | yes | — | Starting entity value |
max_depth |
integer | no | 2 |
Maximum traversal depth |
Request:
{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "zettelforge_graph",
"arguments": {
"type": "actor",
"value": "apt28",
"max_depth": 2
}
}
}
Response fields:
| Field | Type | Description |
|---|---|---|
paths |
object[][] | Up to 20 graph traversal paths |
paths[][].from |
string | Source entity value |
paths[][].rel |
string | Relationship type |
paths[][].to |
string | Target entity value |
count |
integer | Number of paths found |
zettelforge_stats¶
Return memory system statistics: version, total note count, retrieval count, and entity index breakdown.
Input schema: No parameters.
Request:
{
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {
"name": "zettelforge_stats",
"arguments": {}
}
}
Response fields:
| Field | Type | Description |
|---|---|---|
version |
string | ZettelForge version string (e.g. "2.7.0") |
total_notes |
integer | Total number of stored notes |
retrievals |
integer | Cumulative retrieval count since last restart |
entity_index |
object | Entity type counts; each key maps to {unique_entities, total_mappings} |
The entity_index keys reflect what has been ingested. A populated deployment includes:
cve, intrusion_set, actor, tool, campaign, attack_pattern, ipv4, domain,
url, md5, sha1, sha256, email, person, location, organization, event,
activity, temporal. Types with no data still appear with zero counts.
zettelforge_sync¶
Trigger an OpenCTI sync. This tool is present in the tool list for all installations, but full sync functionality is available via ThreatRecall.ai SaaS. When called against an OSS installation without the sync extension, the tool returns an error.
Input schema:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
limit |
integer | no | 20 |
Maximum objects to pull per STIX type |
Request:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "zettelforge_sync",
"arguments": {
"limit": 20
}
}
}
Response (OSS installation):
The tool returns an error response indicating that the sync extension is not installed. This is isError: false at the JSON-RPC level — the call succeeds; the error is inside the content text.
Response (ThreatRecall.ai SaaS):
Full sync results with per-type object counts. See the ThreatRecall.ai SaaS documentation for the exact response shape.
JSON-RPC methods¶
initialize¶
The client sends initialize as the first message to negotiate protocol version and discover server capabilities.
Request:
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "my-client",
"version": "1.0.0"
}
}
}
Response:
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {
"listChanged": false
}
},
"serverInfo": {
"name": "zettelforge",
"version": "2.7.0"
}
}
}
notifications/initialized¶
Sent by the client after receiving the initialize response. The server does not send a response; it silently skips this message.
Request:
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
tools/list¶
Return the full list of available tools with their input schemas.
Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
Response: Returns a tools array containing all 7 tool definitions, each with name, description, and inputSchema.
tools/call¶
Execute a named tool with the provided arguments.
Request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "zettelforge_recall",
"arguments": {
"query": "APT28",
"k": 5
}
}
}
Response (success):
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{ ... }"
}
]
}
}
Response (tool error):
Tool errors do not use JSON-RPC error codes. They are returned as successful JSON-RPC responses with isError: true in the result payload and the error message inside the text content:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{\"error\": \"Unknown tool: zettelforge_nonexistent\"}"
}
],
"isError": true
}
}
Error codes¶
JSON-RPC standard error codes¶
| Code | Meaning | When it occurs |
|---|---|---|
-32700 |
Parse error | Invalid JSON in request |
-32600 |
Invalid request | Request object is malformed |
-32601 |
Method not found | Unknown method (not initialize, tools/list, tools/call, or notifications/initialized) |
-32602 |
Invalid params | Tool arguments fail schema validation (handled by the MCP client; the server does not validate schemas) |
-32603 |
Internal error | Unhandled exception in the tool handler |
Method-not-found response example¶
{
"jsonrpc": "2.0",
"id": 9,
"error": {
"code": -32601,
"message": "Unknown method: does/not/exist"
}
}
Tool-level errors¶
| Scenario | Error message |
|---|---|
| Unknown tool name | "Unknown tool: {name}" |
| OpenCTI sync not available | Error indicating the sync extension is not installed |
| OpenCTI sync failure | Exception message from the sync extension |
| Backend connection failure | Connection error from MemoryManager |
Backward compatibility¶
Tool names prefixed with threatrecall_ (for example threatrecall_stats, threatrecall_remember) are transparently rewritten to zettelforge_* before dispatch:
if name.startswith("threatrecall_"):
name = name.replace("threatrecall_", "zettelforge_", 1)
Existing agent workflows and configurations that reference the old naming continue to work without changes.
Implementation details¶
Server source location¶
| File | Purpose |
|---|---|
src/zettelforge/mcp/server.py |
Core logic: TOOLS, handle_tool_call(), run_stdio(), get_mm() |
src/zettelforge/mcp/__init__.py |
Public API re-export |
src/zettelforge/mcp/__main__.py |
Entrypoint for python -m zettelforge.mcp |
Module public API¶
from zettelforge.mcp import TOOLS, handle_tool_call, run_stdio
| Symbol | Type | Description |
|---|---|---|
TOOLS |
list[dict] |
Static tool definitions (7 tools) with input schemas |
handle_tool_call(name, arguments) |
(str, dict) -> dict |
Route tool name and args to MemoryManager methods |
run_stdio() |
() -> None |
Start the stdio-based JSON-RPC loop |
Environment variables¶
| Variable | Default | Description |
|---|---|---|
ZETTELFORGE_BACKEND |
sqlite |
Storage backend: sqlite, jsonl, typedb, lancedb. Set at import time; ensures the server works out of the box. |
ZETTELFORGE_HOME |
~/.zettelforge |
Memory store directory |
Test coverage¶
Unit tests are in tests/test_mcp_server.py and cover:
- Lazy singleton contract (import does not instantiate MemoryManager)
initializehandshake response structuretools/listreturns all 7 tools with valid schemas- Unknown method returns JSON-RPC error code
-32601 notifications/initializedproduces no responsethreatrecall_*backward-compatible name rewriting