memro-mcp 0.2.1__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.
- memro_mcp/__init__.py +4 -0
- memro_mcp/sdk.py +88 -0
- memro_mcp/server.py +792 -0
- memro_mcp/supermemory_tools.py +183 -0
- memro_mcp-0.2.1.dist-info/METADATA +84 -0
- memro_mcp-0.2.1.dist-info/RECORD +10 -0
- memro_mcp-0.2.1.dist-info/WHEEL +5 -0
- memro_mcp-0.2.1.dist-info/entry_points.txt +2 -0
- memro_mcp-0.2.1.dist-info/licenses/LICENSE +75 -0
- memro_mcp-0.2.1.dist-info/top_level.txt +1 -0
memro_mcp/__init__.py
ADDED
memro_mcp/sdk.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
class MemroSDK:
|
|
6
|
+
"""
|
|
7
|
+
Simplified Developer SDK for Memro: The Active Knowledge Layer for AI Agents.
|
|
8
|
+
|
|
9
|
+
This SDK abstracts away the complexity of the MEE (Memory Evolution Engine)
|
|
10
|
+
and providing a human-first interface for AI agent memory.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, agent_id: str, token: str, base_url: str = "http://localhost:8081"):
|
|
13
|
+
self.agent_id = agent_id
|
|
14
|
+
self.token = token
|
|
15
|
+
self.base_url = base_url
|
|
16
|
+
self.headers = {
|
|
17
|
+
"Authorization": f"Bearer {token}",
|
|
18
|
+
"X-Agent-Id": agent_id,
|
|
19
|
+
"Content-Type": "application/json"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async def remember(self, content: str, memory_type: str = "episodic", metadata: Dict[str, Any] = None):
|
|
23
|
+
"""
|
|
24
|
+
Store a new memory for the agent.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
content: The text content of the memory.
|
|
28
|
+
memory_type: 'episodic' (event) or 'semantic' (fact). Defaults to 'episodic'.
|
|
29
|
+
metadata: Optional dictionary of additional context.
|
|
30
|
+
"""
|
|
31
|
+
async with httpx.AsyncClient() as client:
|
|
32
|
+
response = await client.post(
|
|
33
|
+
f"{self.base_url}/memories",
|
|
34
|
+
json={"content": content, "memory_type": memory_type, "metadata": metadata or {}},
|
|
35
|
+
headers=self.headers
|
|
36
|
+
)
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
return response.json()
|
|
39
|
+
|
|
40
|
+
async def recall(self, query: str, limit: int = 5):
|
|
41
|
+
"""
|
|
42
|
+
Retrieve the most relevant memories based on semantic similarity.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
query: The search string.
|
|
46
|
+
limit: Maximum number of memories to return.
|
|
47
|
+
"""
|
|
48
|
+
async with httpx.AsyncClient() as client:
|
|
49
|
+
response = await client.post(
|
|
50
|
+
f"{self.base_url}/search",
|
|
51
|
+
json={"query": query, "limit": limit},
|
|
52
|
+
headers=self.headers
|
|
53
|
+
)
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
return response.json()
|
|
56
|
+
|
|
57
|
+
async def reason(self, query: str, complex: bool = False):
|
|
58
|
+
"""
|
|
59
|
+
Ask the Memory Evolution Engine (MEE) to reason over stored knowledge.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
query: The question or reasoning prompt.
|
|
63
|
+
complex: If True, uses the 'Deep Path' (causal analysis). Defaults to False (Fast Path).
|
|
64
|
+
"""
|
|
65
|
+
endpoint = "/mee/reason"
|
|
66
|
+
# The engine uses heuristics to decide between Fast and Deep paths based on query keywords,
|
|
67
|
+
# but the SDK can eventually support explicit mode selection.
|
|
68
|
+
async with httpx.AsyncClient() as client:
|
|
69
|
+
response = await client.post(
|
|
70
|
+
f"{self.base_url}{endpoint}",
|
|
71
|
+
json={"query": query},
|
|
72
|
+
headers=self.headers
|
|
73
|
+
)
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
return response.json()
|
|
76
|
+
|
|
77
|
+
async def get_usage(self):
|
|
78
|
+
"""
|
|
79
|
+
Fetch real-time usage metrics for the current agent.
|
|
80
|
+
Includes reasoning call counts and graph statistics.
|
|
81
|
+
"""
|
|
82
|
+
async with httpx.AsyncClient() as client:
|
|
83
|
+
response = await client.get(
|
|
84
|
+
f"{self.base_url}/usage",
|
|
85
|
+
headers=self.headers
|
|
86
|
+
)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
return response.json()
|
memro_mcp/server.py
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
import re
|
|
8
|
+
import hashlib
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from contextvars import ContextVar
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
from urllib.parse import parse_qs
|
|
15
|
+
|
|
16
|
+
# Load local .env if it exists
|
|
17
|
+
load_dotenv()
|
|
18
|
+
from mcp.server import Server
|
|
19
|
+
from mcp.server.stdio import stdio_server
|
|
20
|
+
from mcp.server.sse import SseServerTransport
|
|
21
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
22
|
+
import contextlib
|
|
23
|
+
from mcp.types import Tool, TextContent, CallToolResult, JSONRPCNotification
|
|
24
|
+
from memro import Agent, MemroClient, MemroError
|
|
25
|
+
from starlette.applications import Starlette
|
|
26
|
+
from starlette.requests import Request
|
|
27
|
+
from starlette.routing import Route, Mount
|
|
28
|
+
from starlette.responses import Response, JSONResponse, PlainTextResponse
|
|
29
|
+
import uvicorn
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import uvloop
|
|
33
|
+
uvloop_available = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
uvloop_available = False
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from importlib.metadata import version as _version
|
|
39
|
+
SERVER_VERSION = _version("memro-mcp")
|
|
40
|
+
except Exception:
|
|
41
|
+
SERVER_VERSION = "0.0.0"
|
|
42
|
+
|
|
43
|
+
# --- Core Objects (Module Level for Uvicorn Workers) ---
|
|
44
|
+
# Sent to every client on connect (MCP "instructions"); clients such as Claude
|
|
45
|
+
# Code put it in the agent's context, so tools use Memro without being told.
|
|
46
|
+
# memro-cli writes the same guidance into tools' global instruction files for
|
|
47
|
+
# clients that ignore this; keep the two in step.
|
|
48
|
+
INSTRUCTIONS = """\
|
|
49
|
+
Memro is the user's long-term memory, shared across their AI tools and sessions.
|
|
50
|
+
|
|
51
|
+
Use it without being asked:
|
|
52
|
+
- At the start of a task or conversation, call `recall` with a short query about \
|
|
53
|
+
the task (project, topic, people involved) and use what comes back.
|
|
54
|
+
- Before answering questions about earlier work ("what did we decide about X?"), \
|
|
55
|
+
`recall` first.
|
|
56
|
+
- Call `remember` with one short, self-contained sentence when something will \
|
|
57
|
+
matter in a later session: a decision and why, a fix or workflow worth reusing, \
|
|
58
|
+
a preference or correction from the user, or an important project constraint.
|
|
59
|
+
|
|
60
|
+
Types: "semantic" for facts ("Memro uses PostgreSQL"), "profile" for the user's \
|
|
61
|
+
preferences and working style ("User prefers reviewing migrations before \
|
|
62
|
+
deploys"), "procedural" for how-tos ("To run integration tests, start PostgreSQL \
|
|
63
|
+
first"), "episodic" for significant events ("The billing isolation tests \
|
|
64
|
+
passed"). If unsure, leave the type out and Memro chooses.
|
|
65
|
+
|
|
66
|
+
Don't save routine events (connecting a tool, starting a session), passing \
|
|
67
|
+
status, facts Memro already has (recall first), or summaries of a whole \
|
|
68
|
+
conversation. Never store secrets (API keys, passwords, tokens, private keys) or \
|
|
69
|
+
anything the user asks you not to keep. Don't announce routine saves; mention a \
|
|
70
|
+
memory only when it changes your answer."""
|
|
71
|
+
|
|
72
|
+
server = Server("memro-memory", version=SERVER_VERSION, instructions=INSTRUCTIONS)
|
|
73
|
+
# Use a path that will be appended to our mount point
|
|
74
|
+
sse = SseServerTransport("/messages/")
|
|
75
|
+
|
|
76
|
+
# Context variables for request-scoped state
|
|
77
|
+
current_session_id: ContextVar[str] = ContextVar("session_id", default="default")
|
|
78
|
+
current_auth_info: ContextVar[dict] = ContextVar("auth_info", default={})
|
|
79
|
+
# The agent an SSE connection authenticated as; tool calls on that connection
|
|
80
|
+
# run in its context.
|
|
81
|
+
current_agent: ContextVar = ContextVar("agent", default=None)
|
|
82
|
+
# The end user's address, passed on to the API so its per-IP rate limit
|
|
83
|
+
# applies per user rather than to the MCP server as a whole.
|
|
84
|
+
current_client_ip: ContextVar = ContextVar("client_ip", default=None)
|
|
85
|
+
|
|
86
|
+
# Over HTTP every request must bring its own credential. The environment
|
|
87
|
+
# fallbacks (MEMRO_API_KEY, MEMRO_AGENT_ID/MEMRO_PRIVATE_KEY, a throwaway
|
|
88
|
+
# identity) are only for a local stdio server, where there is one user.
|
|
89
|
+
HTTP_MODE = os.getenv("MCP_TRANSPORT", "stdio").lower() != "stdio"
|
|
90
|
+
# Legacy: accept an agent's private key as the SSE token. Off by default,
|
|
91
|
+
# since the key travels in the URL.
|
|
92
|
+
ALLOW_PRIVATE_KEY_AUTH = os.getenv("MCP_ALLOW_PRIVATE_KEY_AUTH") == "1"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AuthRequired(Exception):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# sha256(API key) -> (Agent, resolved at), so whoami runs once per key per
|
|
99
|
+
# TTL rather than on every request. The TTL bounds how long a revoked key can
|
|
100
|
+
# still open a session (its tool calls fail at the API right away).
|
|
101
|
+
_api_key_agents: "OrderedDict[str, tuple]" = OrderedDict()
|
|
102
|
+
_api_key_lock = threading.Lock()
|
|
103
|
+
API_KEY_CACHE_SIZE = int(os.getenv("MCP_KEY_CACHE_SIZE", "10000"))
|
|
104
|
+
API_KEY_CACHE_TTL = float(os.getenv("MCP_KEY_CACHE_TTL", "300"))
|
|
105
|
+
API_KEY_PREFIX = "memro_sk_"
|
|
106
|
+
# Read the caller's address from X-Real-IP only behind a proxy that sets it;
|
|
107
|
+
# otherwise anyone could pick the address the API rate-limits them by.
|
|
108
|
+
TRUST_PROXY_HEADERS = os.getenv("MCP_TRUST_PROXY_HEADERS", "").lower() in ("1", "true")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ForwardingClient(MemroClient):
|
|
112
|
+
"""MemroClient that tells the API which end user a request is for."""
|
|
113
|
+
|
|
114
|
+
def _headers(self, method, path_and_query, body, agent_id, private_key) -> dict:
|
|
115
|
+
headers = super()._headers(method, path_and_query, body, agent_id, private_key)
|
|
116
|
+
ip = current_client_ip.get()
|
|
117
|
+
if ip:
|
|
118
|
+
headers["X-Real-IP"] = ip
|
|
119
|
+
return headers
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def agent_for_api_key(api_key: str) -> Agent:
|
|
123
|
+
digest = hashlib.sha256(api_key.encode()).hexdigest()
|
|
124
|
+
now = time.monotonic()
|
|
125
|
+
with _api_key_lock:
|
|
126
|
+
hit = _api_key_agents.get(digest)
|
|
127
|
+
if hit and now - hit[1] < API_KEY_CACHE_TTL:
|
|
128
|
+
_api_key_agents.move_to_end(digest)
|
|
129
|
+
return hit[0]
|
|
130
|
+
base_url = os.getenv("MEMRO_BASE_URL", "http://localhost:8081")
|
|
131
|
+
agent = Agent.from_api_key(api_key, client=ForwardingClient(base_url, api_key=api_key))
|
|
132
|
+
with _api_key_lock:
|
|
133
|
+
_api_key_agents[digest] = (agent, now)
|
|
134
|
+
_api_key_agents.move_to_end(digest)
|
|
135
|
+
while len(_api_key_agents) > API_KEY_CACHE_SIZE:
|
|
136
|
+
_api_key_agents.popitem(last=False)
|
|
137
|
+
return agent
|
|
138
|
+
|
|
139
|
+
# Logs go to stderr (stdout is the stdio transport). Tool arguments contain
|
|
140
|
+
# memory content and are never logged.
|
|
141
|
+
logging.basicConfig(
|
|
142
|
+
stream=sys.stderr,
|
|
143
|
+
level=os.getenv("MCP_LOG_LEVEL", "INFO").upper(),
|
|
144
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
145
|
+
)
|
|
146
|
+
logger = logging.getLogger("memro_mcp")
|
|
147
|
+
|
|
148
|
+
# --- Coordination ---
|
|
149
|
+
|
|
150
|
+
class Decision:
|
|
151
|
+
ALLOW = "allow"
|
|
152
|
+
WARN = "warn"
|
|
153
|
+
BLOCK = "block"
|
|
154
|
+
|
|
155
|
+
# --- Helper Functions ---
|
|
156
|
+
|
|
157
|
+
def resolve_agent(agent_id: str = None, token: str = None) -> Agent:
|
|
158
|
+
"""Return the agent for a credential. Raises AuthRequired over HTTP when
|
|
159
|
+
the request has no usable credential."""
|
|
160
|
+
if token and token.startswith(API_KEY_PREFIX):
|
|
161
|
+
return agent_for_api_key(token)
|
|
162
|
+
|
|
163
|
+
base_url = os.getenv("MEMRO_BASE_URL", "http://localhost:8081")
|
|
164
|
+
if HTTP_MODE:
|
|
165
|
+
if ALLOW_PRIVATE_KEY_AUTH and token and agent_id:
|
|
166
|
+
key = token[3:] if token.startswith("sk_") else token
|
|
167
|
+
return Agent(agent_id=agent_id, private_key=key, client=ForwardingClient(base_url))
|
|
168
|
+
raise AuthRequired("Send a memro_sk_ API key (Authorization: Bearer ...). Run `npx memro-cli connect` to get one.")
|
|
169
|
+
|
|
170
|
+
if os.getenv("MEMRO_API_KEY"):
|
|
171
|
+
return agent_for_api_key(os.getenv("MEMRO_API_KEY"))
|
|
172
|
+
if os.getenv("MEMRO_AGENT_ID") and os.getenv("MEMRO_PRIVATE_KEY"):
|
|
173
|
+
return Agent.from_env(client=ForwardingClient(base_url))
|
|
174
|
+
global _local_agent
|
|
175
|
+
if _local_agent is None:
|
|
176
|
+
_local_agent = Agent.create(client=ForwardingClient(base_url))
|
|
177
|
+
return _local_agent
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_local_agent = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def current_request_agent() -> Agent:
|
|
184
|
+
agent = current_agent.get()
|
|
185
|
+
if agent is not None:
|
|
186
|
+
return agent
|
|
187
|
+
auth = current_auth_info.get()
|
|
188
|
+
return resolve_agent(auth.get("agent_id"), auth.get("token"))
|
|
189
|
+
|
|
190
|
+
# --- MCP Tool Handlers ---
|
|
191
|
+
|
|
192
|
+
from memro_mcp.supermemory_tools import get_supermemory_tools
|
|
193
|
+
|
|
194
|
+
@server.list_tools()
|
|
195
|
+
async def list_tools() -> list[Tool]:
|
|
196
|
+
# Clients reject duplicate tool names; the core definitions win over the
|
|
197
|
+
# supermemory-compatible set, which repeats remember_with_context.
|
|
198
|
+
unique: dict[str, Tool] = {}
|
|
199
|
+
for tool in _all_tools():
|
|
200
|
+
unique.setdefault(tool.name, tool)
|
|
201
|
+
return list(unique.values())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# A memory's type, for the remember tools. No default: an omitted type lets the
|
|
205
|
+
# server classify the memory instead of filing everything as episodic.
|
|
206
|
+
MEMORY_TYPE = {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"enum": ["semantic", "profile", "procedural", "episodic"],
|
|
209
|
+
"description": (
|
|
210
|
+
"semantic: a fact. profile: the user's preferences or working style. "
|
|
211
|
+
"procedural: how to do something. episodic: a significant event. "
|
|
212
|
+
"Omit if unsure and Memro chooses."
|
|
213
|
+
),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _memory_type(args: dict):
|
|
218
|
+
"""The caller's type, or None so the server classifies the memory."""
|
|
219
|
+
value = args.get("type")
|
|
220
|
+
return value.strip().lower() if isinstance(value, str) and value.strip() else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _all_tools() -> list[Tool]:
|
|
224
|
+
logger.info("Listing tools for client")
|
|
225
|
+
return [
|
|
226
|
+
Tool(
|
|
227
|
+
name="remember",
|
|
228
|
+
description=(
|
|
229
|
+
"Save something worth knowing in future sessions: a preference, decision, "
|
|
230
|
+
"correction, or lasting fact about the user or their project. One short, "
|
|
231
|
+
"self-contained sentence per memory. Never store secrets."
|
|
232
|
+
),
|
|
233
|
+
inputSchema={
|
|
234
|
+
"type": "object",
|
|
235
|
+
"properties": {
|
|
236
|
+
"content": {"type": "string"},
|
|
237
|
+
"type": MEMORY_TYPE,
|
|
238
|
+
"visibility": {"type": "string", "enum": ["private", "shared", "public"], "default": "private"},
|
|
239
|
+
"event_date": {"type": "string"},
|
|
240
|
+
"session_id": {"type": "string"},
|
|
241
|
+
"is_atomic": {"type": "boolean", "default": False},
|
|
242
|
+
},
|
|
243
|
+
"required": ["content"],
|
|
244
|
+
},
|
|
245
|
+
),
|
|
246
|
+
Tool(
|
|
247
|
+
name="recall",
|
|
248
|
+
description=(
|
|
249
|
+
"Search the user's long-term memory. Call it at the start of a task and "
|
|
250
|
+
"before answering questions about earlier work, preferences or decisions."
|
|
251
|
+
),
|
|
252
|
+
inputSchema={
|
|
253
|
+
"type": "object",
|
|
254
|
+
"properties": {
|
|
255
|
+
"query": {"type": "string"},
|
|
256
|
+
"limit": {"type": "integer", "default": 5},
|
|
257
|
+
"type": {"type": "string", "enum": ["episodic", "semantic", "procedural", "profile"]},
|
|
258
|
+
},
|
|
259
|
+
"required": ["query"],
|
|
260
|
+
},
|
|
261
|
+
),
|
|
262
|
+
Tool(
|
|
263
|
+
name="claim_task",
|
|
264
|
+
description=(
|
|
265
|
+
"Claim a file or task with intent to work on it. Claims are shared with "
|
|
266
|
+
"agents in the same account/space and expire after the TTL."
|
|
267
|
+
),
|
|
268
|
+
inputSchema={
|
|
269
|
+
"type": "object",
|
|
270
|
+
"properties": {
|
|
271
|
+
"resource": {"type": "string", "description": "File path or module"},
|
|
272
|
+
"intent": {"type": "string", "description": "What you plan to do"},
|
|
273
|
+
"ttl": {"type": "integer", "description": "Lease duration in seconds (default 900, max 7200)"},
|
|
274
|
+
},
|
|
275
|
+
"required": ["resource", "intent"],
|
|
276
|
+
},
|
|
277
|
+
),
|
|
278
|
+
Tool(
|
|
279
|
+
name="heartbeat_task",
|
|
280
|
+
description="Extend an active lease on a resource so it does not expire.",
|
|
281
|
+
inputSchema={
|
|
282
|
+
"type": "object",
|
|
283
|
+
"properties": {
|
|
284
|
+
"resource": {"type": "string", "description": "Resource to heartbeat"},
|
|
285
|
+
"ttl": {"type": "integer", "description": "New TTL in seconds from now (default 900, max 7200)"},
|
|
286
|
+
},
|
|
287
|
+
"required": ["resource"],
|
|
288
|
+
},
|
|
289
|
+
),
|
|
290
|
+
Tool(
|
|
291
|
+
name="release_task",
|
|
292
|
+
description="Release a claimed resource and log outcome. Releases the lease in the shared account/space.",
|
|
293
|
+
inputSchema={
|
|
294
|
+
"type": "object",
|
|
295
|
+
"properties": {
|
|
296
|
+
"resource": {"type": "string"},
|
|
297
|
+
"status": {"type": "string", "enum": ["completed", "abandoned", "cancelled", "handoff"], "default": "completed"},
|
|
298
|
+
},
|
|
299
|
+
"required": ["resource"],
|
|
300
|
+
},
|
|
301
|
+
),
|
|
302
|
+
Tool(
|
|
303
|
+
name="log_intent",
|
|
304
|
+
description="Declare your non-blocking intention for a resource (shared with agents in the same account/space).",
|
|
305
|
+
inputSchema={
|
|
306
|
+
"type": "object",
|
|
307
|
+
"properties": {
|
|
308
|
+
"resource": {"type": "string"},
|
|
309
|
+
"intent": {"type": "string"},
|
|
310
|
+
"ttl": {"type": "integer", "description": "Intent duration in seconds (default 900, max 7200)"},
|
|
311
|
+
},
|
|
312
|
+
"required": ["resource", "intent"],
|
|
313
|
+
},
|
|
314
|
+
),
|
|
315
|
+
Tool(
|
|
316
|
+
name="handoff_task",
|
|
317
|
+
description="Atomically summarizes work and releases a resource claim for the next agent.",
|
|
318
|
+
inputSchema={
|
|
319
|
+
"type": "object",
|
|
320
|
+
"properties": {
|
|
321
|
+
"resource": {"type": "string"},
|
|
322
|
+
"summary": {"type": "string"},
|
|
323
|
+
"next_agent_id": {"type": "string"},
|
|
324
|
+
},
|
|
325
|
+
"required": ["resource", "summary"],
|
|
326
|
+
},
|
|
327
|
+
),
|
|
328
|
+
Tool(
|
|
329
|
+
name="pre_action_check",
|
|
330
|
+
description="Check for conflicts or overlaps before performing an action against shared leases and intents.",
|
|
331
|
+
inputSchema={
|
|
332
|
+
"type": "object",
|
|
333
|
+
"properties": {
|
|
334
|
+
"resource": {"type": "string"},
|
|
335
|
+
"intent": {"type": "string"},
|
|
336
|
+
},
|
|
337
|
+
"required": ["resource", "intent"],
|
|
338
|
+
},
|
|
339
|
+
),
|
|
340
|
+
Tool(
|
|
341
|
+
name="delete_memory",
|
|
342
|
+
description="Delete one memory by ID, e.g. when the user says it is wrong or asks you to forget it.",
|
|
343
|
+
inputSchema={
|
|
344
|
+
"type": "object",
|
|
345
|
+
"properties": {
|
|
346
|
+
"memory_id": {"type": "string"},
|
|
347
|
+
},
|
|
348
|
+
"required": ["memory_id"],
|
|
349
|
+
},
|
|
350
|
+
),
|
|
351
|
+
Tool(
|
|
352
|
+
name="export_memories",
|
|
353
|
+
description="Export all memories for the agent.",
|
|
354
|
+
inputSchema={"type": "object", "properties": {}},
|
|
355
|
+
),
|
|
356
|
+
Tool(
|
|
357
|
+
name="get_recent_memories",
|
|
358
|
+
description="List the most recently saved memories (newest first), e.g. to review what was stored.",
|
|
359
|
+
inputSchema={
|
|
360
|
+
"type": "object",
|
|
361
|
+
"properties": {
|
|
362
|
+
"limit": {"type": "integer", "default": 10},
|
|
363
|
+
"type": {"type": "string", "enum": ["episodic", "semantic", "procedural", "profile"]},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
),
|
|
367
|
+
Tool(
|
|
368
|
+
name="remember_with_context",
|
|
369
|
+
description="Store a new memory with rich metadata context (file, git, function).",
|
|
370
|
+
inputSchema={
|
|
371
|
+
"type": "object",
|
|
372
|
+
"properties": {
|
|
373
|
+
"content": {"type": "string"},
|
|
374
|
+
"context_metadata": {"type": "object"},
|
|
375
|
+
"type": MEMORY_TYPE,
|
|
376
|
+
"event_date": {"type": "string"},
|
|
377
|
+
"session_id": {"type": "string"},
|
|
378
|
+
},
|
|
379
|
+
"required": ["content"],
|
|
380
|
+
},
|
|
381
|
+
),
|
|
382
|
+
Tool(
|
|
383
|
+
name="get_active_work",
|
|
384
|
+
description="Get ALL active claims and intents for the shared account/space.",
|
|
385
|
+
inputSchema={"type": "object", "properties": {}},
|
|
386
|
+
),
|
|
387
|
+
Tool(
|
|
388
|
+
name="get_active_context",
|
|
389
|
+
description="Get coordination context (claims/intents) for a specific resource in the shared account/space.",
|
|
390
|
+
inputSchema={
|
|
391
|
+
"type": "object",
|
|
392
|
+
"properties": {
|
|
393
|
+
"resource": {"type": "string", "description": "File or resource to check"},
|
|
394
|
+
},
|
|
395
|
+
"required": ["resource"],
|
|
396
|
+
},
|
|
397
|
+
),
|
|
398
|
+
Tool(
|
|
399
|
+
name="get_system_status",
|
|
400
|
+
description="Check the health and status of the Memro MCP server and its connections.",
|
|
401
|
+
inputSchema={"type": "object", "properties": {}},
|
|
402
|
+
),
|
|
403
|
+
*get_supermemory_tools()
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
def _text(value) -> CallToolResult:
|
|
407
|
+
if not isinstance(value, str):
|
|
408
|
+
value = json.dumps(value, indent=2, default=str)
|
|
409
|
+
return CallToolResult(content=[TextContent(type="text", text=value)])
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _dump(model) -> dict:
|
|
413
|
+
return model.model_dump(mode="json") if hasattr(model, "model_dump") else model
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
MAX_LIMIT = 50
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _limit(args: dict, default: int) -> int:
|
|
420
|
+
"""The caller's limit, clamped so one call cannot pull an unbounded page."""
|
|
421
|
+
try:
|
|
422
|
+
return max(1, min(int(args.get("limit", default)), MAX_LIMIT))
|
|
423
|
+
except (TypeError, ValueError):
|
|
424
|
+
return default
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _run_tool(agent: Agent, session_id: str, name: str, args: dict) -> CallToolResult:
|
|
428
|
+
"""Executes one tool. Runs in a worker thread: the SDK is synchronous."""
|
|
429
|
+
if name == "remember":
|
|
430
|
+
memory = agent.remember(content=args.get("content"), type=_memory_type(args),
|
|
431
|
+
visibility=args.get("visibility", "private"),
|
|
432
|
+
event_date=args.get("event_date"), session_id=args.get("session_id"),
|
|
433
|
+
is_atomic=bool(args.get("is_atomic", False)))
|
|
434
|
+
return _text(f"Remembered: {memory.content[:100]} (ID: {memory.id})")
|
|
435
|
+
|
|
436
|
+
if name == "remember_with_context":
|
|
437
|
+
metadata = args.get("context_metadata")
|
|
438
|
+
memory = agent.remember(content=args.get("content"), type=_memory_type(args),
|
|
439
|
+
event_date=args.get("event_date"), session_id=args.get("session_id"),
|
|
440
|
+
metadata=metadata if isinstance(metadata, dict) else None)
|
|
441
|
+
return _text(f"Remembered with context: {memory.content[:100]} (ID: {memory.id})")
|
|
442
|
+
|
|
443
|
+
if name in ("recall", "recall_multi_session"):
|
|
444
|
+
results = agent.recall(query=args.get("query"), limit=_limit(args, 5 if name == "recall" else 15),
|
|
445
|
+
type=args.get("type"))
|
|
446
|
+
return _text([{"id": r.memory_id, "content": r.content, "type": r.memory_type, "score": r.score,
|
|
447
|
+
"created_at": r.created_at} for r in results])
|
|
448
|
+
|
|
449
|
+
if name == "get_recent_memories":
|
|
450
|
+
memories = agent.get_recent(limit=_limit(args, 10), type=args.get("type"))
|
|
451
|
+
return _text([{"id": m.id, "content": m.content, "type": m.memory_type,
|
|
452
|
+
"created_at": m.created_at.isoformat(), "metadata": m.metadata or {}} for m in memories])
|
|
453
|
+
|
|
454
|
+
if name == "delete_memory":
|
|
455
|
+
return _text("Memory deleted." if agent.delete_memory(args.get("memory_id")) else "Memory not found.")
|
|
456
|
+
|
|
457
|
+
if name == "export_memories":
|
|
458
|
+
return _text(f"Exported {agent.export().total} memories.")
|
|
459
|
+
|
|
460
|
+
if name == "search_temporal":
|
|
461
|
+
results = agent.recall_temporal(
|
|
462
|
+
query=args.get("query"), limit=_limit(args, 10),
|
|
463
|
+
after_date=args.get("after_date"), before_date=args.get("before_date"),
|
|
464
|
+
after_event=args.get("after_event_date"), before_event=args.get("before_event_date"),
|
|
465
|
+
session_id=args.get("session_id"), atomic_only=args.get("atomic_only"),
|
|
466
|
+
temporal_weight=args.get("temporal_weight", 0.3),
|
|
467
|
+
)
|
|
468
|
+
if not results:
|
|
469
|
+
return _text("No matching memories.")
|
|
470
|
+
return _text("\n".join(f"- {r.content} ({r.temporal_context})" for r in results))
|
|
471
|
+
|
|
472
|
+
if name == "get_temporal_stats":
|
|
473
|
+
return _text(_dump(agent.stats()))
|
|
474
|
+
|
|
475
|
+
if name == "get_memory_chain":
|
|
476
|
+
return _text(_dump(agent.memory_chain(args.get("memory_id"))))
|
|
477
|
+
|
|
478
|
+
if name in ("find_contradictions", "analyze_conflicts"):
|
|
479
|
+
return _text(agent.client.analyze_conflicts(agent.agent_id, agent._private_key))
|
|
480
|
+
|
|
481
|
+
if name == "generate_atomic_facts":
|
|
482
|
+
return _text(agent.client.generate_atoms(agent.agent_id, agent._private_key, args.get("text", "")))
|
|
483
|
+
|
|
484
|
+
if name == "mee_reason":
|
|
485
|
+
r = agent.reason(args.get("query"))
|
|
486
|
+
return _text({"answer": r.answer, "confidence": r.confidence, "why": r.why,
|
|
487
|
+
"based_on": r.based_on, "paths": r.paths})
|
|
488
|
+
|
|
489
|
+
if name == "mee_resolve":
|
|
490
|
+
return _text(agent.resolve_conflict(args.get("memory_a_id"), args.get("memory_b_id")))
|
|
491
|
+
|
|
492
|
+
if name == "mee_evolve":
|
|
493
|
+
return _text(_dump(agent.evolve()))
|
|
494
|
+
|
|
495
|
+
if name == "get_timeline":
|
|
496
|
+
return _text(_dump(agent.timeline(args.get("memory_id"))))
|
|
497
|
+
|
|
498
|
+
if name == "pre_action_check":
|
|
499
|
+
return _text(agent.check(resource=args.get("resource"), intent=args.get("intent", ""), session_id=session_id))
|
|
500
|
+
|
|
501
|
+
if name == "claim_task":
|
|
502
|
+
resource, intent = args.get("resource"), args.get("intent")
|
|
503
|
+
ttl = args.get("ttl")
|
|
504
|
+
if ttl is not None:
|
|
505
|
+
try:
|
|
506
|
+
ttl = int(ttl)
|
|
507
|
+
except (ValueError, TypeError):
|
|
508
|
+
ttl = None
|
|
509
|
+
res = agent.claim(resource=resource, intent=intent, session_id=session_id, ttl_seconds=ttl)
|
|
510
|
+
if not res.get("granted"):
|
|
511
|
+
# Tools on one account share an agent id; the session names the tool.
|
|
512
|
+
session = res.get("holder_session")
|
|
513
|
+
who = session if session and session != "default" else f"agent {res.get('holder', 'unknown')}"
|
|
514
|
+
held = res.get("holder_resource")
|
|
515
|
+
where = f"'{held}', which overlaps '{resource}'" if held and held != resource else f"'{resource}'"
|
|
516
|
+
msg = f"Block: {where} is claimed by {who}"
|
|
517
|
+
if res.get("expires_at"):
|
|
518
|
+
msg += f" until {res.get('expires_at')}"
|
|
519
|
+
msg += "."
|
|
520
|
+
if res.get("intent"):
|
|
521
|
+
msg += f" Intent: {res.get('intent')}."
|
|
522
|
+
msg += " Don't edit it; tell the user, or wait for it to be released."
|
|
523
|
+
return CallToolResult(isError=True, content=[TextContent(type="text", text=msg)])
|
|
524
|
+
msg = f"Resource '{resource}' claimed."
|
|
525
|
+
if res.get("handoff_summary"):
|
|
526
|
+
msg += f" Handoff summary from previous agent: {res.get('handoff_summary')}"
|
|
527
|
+
return _text(msg)
|
|
528
|
+
|
|
529
|
+
if name == "heartbeat_task":
|
|
530
|
+
resource = args.get("resource")
|
|
531
|
+
ttl = args.get("ttl")
|
|
532
|
+
if ttl is not None:
|
|
533
|
+
try:
|
|
534
|
+
ttl = int(ttl)
|
|
535
|
+
except (ValueError, TypeError):
|
|
536
|
+
ttl = None
|
|
537
|
+
res = agent.heartbeat(resource=resource, ttl_seconds=ttl, session_id=session_id)
|
|
538
|
+
return _text(f"Resource '{resource}' heartbeat renewed until {res.get('expires_at')}.")
|
|
539
|
+
|
|
540
|
+
if name == "release_task":
|
|
541
|
+
resource = args.get("resource")
|
|
542
|
+
status = args.get("status", "completed")
|
|
543
|
+
if status == "cancelled":
|
|
544
|
+
status = "abandoned"
|
|
545
|
+
agent.release(resource=resource, status=status, session_id=session_id)
|
|
546
|
+
return _text(f"Resource '{resource}' released.")
|
|
547
|
+
|
|
548
|
+
if name == "log_intent":
|
|
549
|
+
intent, resource = args.get("intent"), args.get("resource")
|
|
550
|
+
ttl = args.get("ttl")
|
|
551
|
+
if ttl is not None:
|
|
552
|
+
try:
|
|
553
|
+
ttl = int(ttl)
|
|
554
|
+
except (ValueError, TypeError):
|
|
555
|
+
ttl = None
|
|
556
|
+
agent.log_intent(resource=resource, intent=intent, ttl_seconds=ttl, session_id=session_id)
|
|
557
|
+
return _text(f"Intent for {resource} logged.")
|
|
558
|
+
|
|
559
|
+
if name in ("get_active_work", "get_active_context"):
|
|
560
|
+
resource = args.get("resource")
|
|
561
|
+
active = agent.active(prefix=resource)
|
|
562
|
+
return _text({"claims": active.get("leases", []), "intents": active.get("intents", [])})
|
|
563
|
+
|
|
564
|
+
if name == "handoff_task":
|
|
565
|
+
resource, summary, nxt = args.get("resource"), args.get("summary"), args.get("next_agent_id")
|
|
566
|
+
agent.handoff(resource=resource, summary=summary, next_agent_id=nxt, session_id=session_id)
|
|
567
|
+
return _text(f"Handoff completed for {resource}.")
|
|
568
|
+
|
|
569
|
+
if name == "get_system_status":
|
|
570
|
+
return _text({"status": "online", "version": SERVER_VERSION, "agent_id": agent.agent_id,
|
|
571
|
+
"api": agent.client.base_url, "api_health": agent.client.health()})
|
|
572
|
+
|
|
573
|
+
return CallToolResult(isError=True, content=[TextContent(type="text", text=f"Unknown tool: {name}")])
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@server.call_tool()
|
|
577
|
+
async def call_tool(name: str, arguments: dict) -> CallToolResult:
|
|
578
|
+
"""Runs a tool as the connection's agent, off the event loop so one slow
|
|
579
|
+
API call does not stall other users' requests."""
|
|
580
|
+
try:
|
|
581
|
+
agent = current_request_agent()
|
|
582
|
+
logger.info(f"Tool call: {name} | agent={agent.agent_id[:12]}")
|
|
583
|
+
# to_thread copies the context, so the caller's IP is still forwarded.
|
|
584
|
+
return await asyncio.to_thread(_run_tool, agent, current_session_id.get(), name, arguments or {})
|
|
585
|
+
except MemroError as e:
|
|
586
|
+
return CallToolResult(isError=True, content=[TextContent(type="text", text=f"Memro API error {e.status_code}: {e.message}")])
|
|
587
|
+
except AuthRequired as e:
|
|
588
|
+
return CallToolResult(isError=True, content=[TextContent(type="text", text=str(e))])
|
|
589
|
+
except Exception:
|
|
590
|
+
# The details stay in our logs; the client gets an ID to quote.
|
|
591
|
+
ref = uuid.uuid4().hex[:12]
|
|
592
|
+
logger.exception("Error in call_tool %s (ref %s)", name, ref)
|
|
593
|
+
return CallToolResult(isError=True, content=[TextContent(type="text", text=f"Tool {name} failed (ref {ref}).")])
|
|
594
|
+
|
|
595
|
+
# --- ASGI / SSE Application Logic ---
|
|
596
|
+
# --- Middleware for Auth/Context ---
|
|
597
|
+
|
|
598
|
+
class AuthMiddleware:
|
|
599
|
+
def __init__(self, app):
|
|
600
|
+
self.app = app
|
|
601
|
+
|
|
602
|
+
async def __call__(self, scope, receive, send):
|
|
603
|
+
if scope["type"] != "http":
|
|
604
|
+
return await self.app(scope, receive, send)
|
|
605
|
+
|
|
606
|
+
path = scope.get("path", "")
|
|
607
|
+
query_string = scope.get("query_string", b"").decode()
|
|
608
|
+
params = parse_qs(query_string)
|
|
609
|
+
|
|
610
|
+
# The coordination session: an explicit ?session_id=, else the MCP
|
|
611
|
+
# transport's per-connection Mcp-Session-Id, so each connected tool
|
|
612
|
+
# (Claude Code, Cursor, ...) is its own holder even on one account.
|
|
613
|
+
session_id = params.get("session_id", [None])[0]
|
|
614
|
+
if not session_id:
|
|
615
|
+
sid = dict(scope.get("headers") or []).get(b"mcp-session-id", b"").decode().strip()
|
|
616
|
+
session_id = f"mcp:{sid}" if sid else None
|
|
617
|
+
if not session_id:
|
|
618
|
+
# Stateless transport and no ?session_id= (tools connected before
|
|
619
|
+
# `memro connect` added it): tell tools apart by User-Agent.
|
|
620
|
+
session_id = _session_from_user_agent(
|
|
621
|
+
dict(scope.get("headers") or []).get(b"user-agent", b"").decode(errors="replace"))
|
|
622
|
+
agent_id = token = None
|
|
623
|
+
# Only the legacy SSE transport takes credentials in the URL, where
|
|
624
|
+
# they end up in proxy logs; everything else needs the header.
|
|
625
|
+
if path.startswith("/sse"):
|
|
626
|
+
agent_id = params.get("agent_id", [None])[0]
|
|
627
|
+
token = params.get("token", [None])[0]
|
|
628
|
+
if not token:
|
|
629
|
+
scheme, _, value = Request(scope, receive).headers.get("Authorization", "").partition(" ")
|
|
630
|
+
if scheme.lower() == "bearer":
|
|
631
|
+
token = value.strip() or None
|
|
632
|
+
|
|
633
|
+
# The edge proxy sets X-Real-IP to the caller's address.
|
|
634
|
+
headers = dict(scope.get("headers") or [])
|
|
635
|
+
forwarded = headers.get(b"x-real-ip", b"").decode().strip() if TRUST_PROXY_HEADERS else ""
|
|
636
|
+
client_ip = forwarded or (scope.get("client") or [None])[0]
|
|
637
|
+
t_ip = current_client_ip.set(client_ip)
|
|
638
|
+
t_sid = current_session_id.set(session_id or "default")
|
|
639
|
+
t_auth = current_auth_info.set({"agent_id": agent_id, "token": token})
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
await self.app(scope, receive, send)
|
|
643
|
+
finally:
|
|
644
|
+
current_auth_info.reset(t_auth)
|
|
645
|
+
current_session_id.reset(t_sid)
|
|
646
|
+
current_client_ip.reset(t_ip)
|
|
647
|
+
|
|
648
|
+
# HTTP libraries and browsers that say nothing about which tool is calling.
|
|
649
|
+
_GENERIC_AGENTS = {"mozilla", "node", "node-fetch", "undici", "axios", "python-httpx", "python-requests",
|
|
650
|
+
"python-urllib", "aiohttp", "curl", "wget", "go-http-client", "okhttp", "java", "dart"}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _session_from_user_agent(ua: str):
|
|
654
|
+
"""'claude-code/2.1 (cli)' -> 'ua:claude-code'; None for generic clients."""
|
|
655
|
+
product = re.sub(r"[^a-z0-9._-]", "", ua.strip().split(" ", 1)[0].split("/", 1)[0].lower())[:40]
|
|
656
|
+
if not product or product in _GENERIC_AGENTS:
|
|
657
|
+
return None
|
|
658
|
+
return f"ua:{product}"
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# --- ASGI SSE Handler ---
|
|
662
|
+
|
|
663
|
+
def _unauthorized(message: str) -> JSONResponse:
|
|
664
|
+
return JSONResponse({"error": "unauthorized", "message": message}, status_code=401,
|
|
665
|
+
headers={"WWW-Authenticate": 'Bearer realm="memro"'})
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
async def _authenticate_request():
|
|
669
|
+
"""Resolve the caller's agent, or return an error response."""
|
|
670
|
+
auth = current_auth_info.get()
|
|
671
|
+
try:
|
|
672
|
+
return await asyncio.to_thread(resolve_agent, auth.get("agent_id"), auth.get("token")), None
|
|
673
|
+
except AuthRequired as e:
|
|
674
|
+
return None, _unauthorized(str(e))
|
|
675
|
+
except MemroError as e:
|
|
676
|
+
if e.status_code in (401, 403):
|
|
677
|
+
return None, _unauthorized(str(e))
|
|
678
|
+
return None, JSONResponse({"error": "upstream_error", "message": str(e)}, status_code=502)
|
|
679
|
+
except Exception:
|
|
680
|
+
logger.exception("Memro API unreachable while checking credentials")
|
|
681
|
+
return None, JSONResponse({"error": "upstream_error", "message": "Memro API is unreachable"}, status_code=502)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
async def handle_sse(scope, receive, send):
|
|
685
|
+
"""Legacy SSE transport. The connection is bound to the agent it
|
|
686
|
+
authenticated as; tool calls on it run as that agent."""
|
|
687
|
+
agent, error = await _authenticate_request()
|
|
688
|
+
if error is not None:
|
|
689
|
+
return await error(scope, receive, send)
|
|
690
|
+
current_agent.set(agent)
|
|
691
|
+
agent_id = agent.agent_id[:12]
|
|
692
|
+
|
|
693
|
+
async def wrapped_send(message):
|
|
694
|
+
if message.get("type") == "http.response.body":
|
|
695
|
+
body = message.get("body", b"").decode(errors="ignore")
|
|
696
|
+
if "event: endpoint" in body:
|
|
697
|
+
# The transport advertises /messages/; it is mounted at /sse/messages/.
|
|
698
|
+
message["body"] = re.sub(r"data: /messages/", "data: /sse/messages/", body).encode()
|
|
699
|
+
await send(message)
|
|
700
|
+
|
|
701
|
+
async with sse.connect_sse(scope, receive, wrapped_send) as (read_stream, write_stream):
|
|
702
|
+
async def heartbeat_loop():
|
|
703
|
+
while True:
|
|
704
|
+
try:
|
|
705
|
+
await asyncio.sleep(15)
|
|
706
|
+
await write_stream.send(JSONRPCNotification(
|
|
707
|
+
method="notifications/heartbeat",
|
|
708
|
+
params={"note": "keep-alive"}
|
|
709
|
+
))
|
|
710
|
+
except Exception: break
|
|
711
|
+
|
|
712
|
+
logger.info(f"Starting server session for agent {agent_id}")
|
|
713
|
+
server_task = asyncio.create_task(server.run(
|
|
714
|
+
read_stream,
|
|
715
|
+
write_stream,
|
|
716
|
+
server.create_initialization_options()
|
|
717
|
+
))
|
|
718
|
+
heartbeat_task = asyncio.create_task(heartbeat_loop())
|
|
719
|
+
|
|
720
|
+
await asyncio.wait([server_task, heartbeat_task], return_when=asyncio.FIRST_COMPLETED)
|
|
721
|
+
heartbeat_task.cancel()
|
|
722
|
+
logger.info(f"Server session terminated for agent {agent_id}")
|
|
723
|
+
|
|
724
|
+
async def handle_messages(scope, receive, send):
|
|
725
|
+
"""Directly proxy POST messages to the transport."""
|
|
726
|
+
logger.debug(f"Proxying POST message for session {current_session_id.get()}")
|
|
727
|
+
await sse.handle_post_message(scope, receive, send)
|
|
728
|
+
|
|
729
|
+
async def handle_healthz(request):
|
|
730
|
+
"""Liveness probe for load balancers and blue-green deploys."""
|
|
731
|
+
return PlainTextResponse("ok")
|
|
732
|
+
|
|
733
|
+
# --- Streamable HTTP (/mcp): the current MCP transport, authenticated by API key ---
|
|
734
|
+
|
|
735
|
+
# Stateless: every request carries its own API key, so any instance (blue or
|
|
736
|
+
# green) can serve it and nothing is lost when a deploy swaps colors.
|
|
737
|
+
session_manager = StreamableHTTPSessionManager(app=server, json_response=False, stateless=True)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class StreamableHTTPApp:
|
|
741
|
+
"""ASGI endpoint for /mcp. Requires `Authorization: Bearer memro_sk_...`."""
|
|
742
|
+
|
|
743
|
+
async def __call__(self, scope, receive, send):
|
|
744
|
+
token = current_auth_info.get().get("token") or ""
|
|
745
|
+
if not token.startswith(API_KEY_PREFIX):
|
|
746
|
+
return await _unauthorized("Send Authorization: Bearer <memro_sk_ API key>. Run `npx memro-cli connect` to get one.")(scope, receive, send)
|
|
747
|
+
_, error = await _authenticate_request()
|
|
748
|
+
if error is not None:
|
|
749
|
+
return await error(scope, receive, send)
|
|
750
|
+
await session_manager.handle_request(scope, receive, send)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@contextlib.asynccontextmanager
|
|
754
|
+
async def lifespan(app):
|
|
755
|
+
async with session_manager.run():
|
|
756
|
+
yield
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# Final App Assembly
|
|
760
|
+
_base_app = Starlette(
|
|
761
|
+
lifespan=lifespan,
|
|
762
|
+
# Debug pages leak stack traces; enable only for local development.
|
|
763
|
+
debug=os.getenv("MCP_DEBUG") == "1",
|
|
764
|
+
routes=[
|
|
765
|
+
Route("/healthz", endpoint=handle_healthz, methods=["GET"]),
|
|
766
|
+
Route("/mcp", endpoint=StreamableHTTPApp(), methods=["GET", "POST", "DELETE"]),
|
|
767
|
+
Mount("/sse/messages", app=handle_messages),
|
|
768
|
+
Mount("/sse", app=handle_sse),
|
|
769
|
+
]
|
|
770
|
+
)
|
|
771
|
+
starlette_app = AuthMiddleware(_base_app)
|
|
772
|
+
|
|
773
|
+
def main():
|
|
774
|
+
transport_mode = os.getenv("MCP_TRANSPORT", "stdio").lower()
|
|
775
|
+
if transport_mode == "sse":
|
|
776
|
+
port = int(os.getenv("MCP_PORT", "8080"))
|
|
777
|
+
workers = int(os.getenv("MCP_WORKERS", "1"))
|
|
778
|
+
loop = "uvloop" if uvloop_available else "auto"
|
|
779
|
+
# uvicorn.run manages its own event loop
|
|
780
|
+
uvicorn.run("memro_mcp.server:starlette_app", host="0.0.0.0", port=port, workers=workers, loop=loop)
|
|
781
|
+
else:
|
|
782
|
+
# stdio requires an active loop for the context manager
|
|
783
|
+
async def run_stdio():
|
|
784
|
+
async with stdio_server() as (read, write):
|
|
785
|
+
await server.run(read, write, server.create_initialization_options())
|
|
786
|
+
|
|
787
|
+
if uvloop_available:
|
|
788
|
+
uvloop.install()
|
|
789
|
+
asyncio.run(run_stdio())
|
|
790
|
+
|
|
791
|
+
if __name__ == "__main__":
|
|
792
|
+
main()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Supermemory-style enhancements for Memro MCP Server
|
|
3
|
+
Implements: temporal reasoning, knowledge chains, atomic memory generation
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
def get_supermemory_tools():
|
|
7
|
+
"""
|
|
8
|
+
Returns list of new Supermemory-enhanced tools for the MCP server.
|
|
9
|
+
These implement temporal grounding, relational versioning, and multi-session reasoning.
|
|
10
|
+
"""
|
|
11
|
+
from mcp.types import Tool
|
|
12
|
+
|
|
13
|
+
return [
|
|
14
|
+
Tool(
|
|
15
|
+
name="search_temporal",
|
|
16
|
+
description=(
|
|
17
|
+
"Search memories with temporal reasoning. Finds relevant memories considering "
|
|
18
|
+
"both when the conversation happened and when events actually occurred. "
|
|
19
|
+
"Perfect for multi-session synthesis and temporal queries."
|
|
20
|
+
),
|
|
21
|
+
inputSchema={
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"query": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "What to search for (e.g., 'things I did last summer')",
|
|
27
|
+
},
|
|
28
|
+
"after_date": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "ISO 8601 date - only return memories from this date forward",
|
|
31
|
+
},
|
|
32
|
+
"before_date": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "ISO 8601 date - only return memories before this date",
|
|
35
|
+
},
|
|
36
|
+
"temporal_weight": {
|
|
37
|
+
"type": "number",
|
|
38
|
+
"minimum": 0.0,
|
|
39
|
+
"maximum": 1.0,
|
|
40
|
+
"default": 0.3,
|
|
41
|
+
"description": "How much to prioritize recency (0=semantic only, 1=temporal only)",
|
|
42
|
+
},
|
|
43
|
+
"atomic_only": {
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"default": False,
|
|
46
|
+
"description": "Only return atomic facts (true) vs raw chunks (false)",
|
|
47
|
+
},
|
|
48
|
+
"limit": {
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"default": 10,
|
|
51
|
+
"maximum": 50,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"required": ["query"],
|
|
55
|
+
},
|
|
56
|
+
),
|
|
57
|
+
Tool(
|
|
58
|
+
name="recall_multi_session",
|
|
59
|
+
description=(
|
|
60
|
+
"Recall memories across multiple sessions with synthesis. "
|
|
61
|
+
"Connects related facts from different conversations and resolves contradictions."
|
|
62
|
+
),
|
|
63
|
+
inputSchema={
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"query": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "What to search for across sessions",
|
|
69
|
+
},
|
|
70
|
+
"include_all_sessions": {
|
|
71
|
+
"type": "boolean",
|
|
72
|
+
"default": True,
|
|
73
|
+
"description": "Search across all past sessions or current session only",
|
|
74
|
+
},
|
|
75
|
+
"limit": {
|
|
76
|
+
"type": "integer",
|
|
77
|
+
"default": 15,
|
|
78
|
+
"description": "Max results from each session",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"required": ["query"],
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
Tool(
|
|
85
|
+
name="get_memory_chain",
|
|
86
|
+
description=(
|
|
87
|
+
"Get the knowledge chain for a memory, showing how it evolved. "
|
|
88
|
+
"Returns updates (state changes), extends (refinements), and derives (inferences)."
|
|
89
|
+
),
|
|
90
|
+
inputSchema={
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"memory_id": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "UUID of the memory to trace",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
"required": ["memory_id"],
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
Tool(
|
|
102
|
+
name="find_contradictions",
|
|
103
|
+
description=(
|
|
104
|
+
"Summarize contradictions the memory engine has found: how many beliefs were "
|
|
105
|
+
"updated by newer memories and how many are still unresolved. Use mee_resolve "
|
|
106
|
+
"to settle a specific pair."
|
|
107
|
+
),
|
|
108
|
+
inputSchema={"type": "object", "properties": {}},
|
|
109
|
+
),
|
|
110
|
+
Tool(
|
|
111
|
+
name="get_temporal_stats",
|
|
112
|
+
description=(
|
|
113
|
+
"Get statistics about your memories: totals for today, this week and this "
|
|
114
|
+
"month, average days between memories, and the count per memory type."
|
|
115
|
+
),
|
|
116
|
+
inputSchema={"type": "object", "properties": {}},
|
|
117
|
+
),
|
|
118
|
+
Tool(
|
|
119
|
+
name="generate_atomic_facts",
|
|
120
|
+
description=(
|
|
121
|
+
"Break a longer narrative into atomic facts for better searchability. "
|
|
122
|
+
"Automatically extracts entities and relationships."
|
|
123
|
+
),
|
|
124
|
+
inputSchema={
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"text": {
|
|
128
|
+
"type": "string",
|
|
129
|
+
"description": "Text to decompose into facts",
|
|
130
|
+
},
|
|
131
|
+
"context": {
|
|
132
|
+
"type": "string",
|
|
133
|
+
"description": "Optional context to resolve pronouns",
|
|
134
|
+
},
|
|
135
|
+
"auto_save": {
|
|
136
|
+
"type": "boolean",
|
|
137
|
+
"default": False,
|
|
138
|
+
"description": "Automatically save generated facts as memories",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
"required": ["text"],
|
|
142
|
+
},
|
|
143
|
+
),
|
|
144
|
+
Tool(
|
|
145
|
+
name="mee_reason",
|
|
146
|
+
description="Trigger the Memory Evolution Engine to reason about a specific query using its internal knowledge graph.",
|
|
147
|
+
inputSchema={
|
|
148
|
+
"type": "object",
|
|
149
|
+
"properties": {
|
|
150
|
+
"query": {"type": "string", "description": "The question or scenario to reason about"}
|
|
151
|
+
},
|
|
152
|
+
"required": ["query"]
|
|
153
|
+
}
|
|
154
|
+
),
|
|
155
|
+
Tool(
|
|
156
|
+
name="mee_resolve",
|
|
157
|
+
description="Resolve a detected conflict between two memories using evidence-based scoring.",
|
|
158
|
+
inputSchema={
|
|
159
|
+
"type": "object",
|
|
160
|
+
"properties": {
|
|
161
|
+
"memory_a_id": {"type": "string", "description": "UUID of the first memory"},
|
|
162
|
+
"memory_b_id": {"type": "string", "description": "UUID of the second memory"}
|
|
163
|
+
},
|
|
164
|
+
"required": ["memory_a_id", "memory_b_id"]
|
|
165
|
+
}
|
|
166
|
+
),
|
|
167
|
+
Tool(
|
|
168
|
+
name="mee_evolve",
|
|
169
|
+
description="Trigger a full evolution cycle (decay, consolidation, conflict detection) for the current agent.",
|
|
170
|
+
inputSchema={"type": "object", "properties": {}}
|
|
171
|
+
),
|
|
172
|
+
Tool(
|
|
173
|
+
name="get_timeline",
|
|
174
|
+
description="Get the full historical timeline of a memory, including all its versions and updates.",
|
|
175
|
+
inputSchema={
|
|
176
|
+
"type": "object",
|
|
177
|
+
"properties": {
|
|
178
|
+
"memory_id": {"type": "string", "description": "UUID of the memory to trace"}
|
|
179
|
+
},
|
|
180
|
+
"required": ["memory_id"]
|
|
181
|
+
}
|
|
182
|
+
),
|
|
183
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memro-mcp
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Memro — verified memory infrastructure for AI agents
|
|
5
|
+
Author-email: memro Team <sdk@memro.co>
|
|
6
|
+
License: LicenseRef-PolyForm-Noncommercial-1.0.0
|
|
7
|
+
Project-URL: Homepage, https://memro.co
|
|
8
|
+
Project-URL: Repository, https://github.com/memrohq/memro
|
|
9
|
+
Keywords: ai-agents,agent-memory,mcp,llm
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: mcp<2,>=1.9
|
|
14
|
+
Requires-Dist: memro<0.3,>=0.2
|
|
15
|
+
Requires-Dist: python-dotenv<2,>=1
|
|
16
|
+
Requires-Dist: starlette<2,>=0.40
|
|
17
|
+
Requires-Dist: uvicorn<1,>=0.30
|
|
18
|
+
Requires-Dist: uvloop>=0.19; sys_platform != "win32"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Memro MCP Server
|
|
22
|
+
|
|
23
|
+
Verified memory infrastructure for AI agents. Gives any Model Context Protocol (MCP) compatible AI tool (Claude Code, Cursor, Codex, Zed, Claude Desktop) persistent memory across sessions.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# PyPI (once published via CI):
|
|
29
|
+
pip install memro-mcp
|
|
30
|
+
|
|
31
|
+
# Or install from source:
|
|
32
|
+
pip install .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Example (MCP Config)
|
|
36
|
+
|
|
37
|
+
Add to your MCP client config (e.g., `~/.claude/claude_desktop_config.json` or `.cursor/mcp.json`):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"memro": {
|
|
43
|
+
"command": "memro-mcp",
|
|
44
|
+
"env": {
|
|
45
|
+
"MEMRO_API_KEY": "memro_sk_..."
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or connect with a single command via the CLI:
|
|
53
|
+
```bash
|
|
54
|
+
npx memro-cli connect
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Available MCP Tools
|
|
58
|
+
|
|
59
|
+
Once connected, agents have direct access to:
|
|
60
|
+
|
|
61
|
+
| Tool | Description |
|
|
62
|
+
|------|-------------|
|
|
63
|
+
| `remember` | Store a new memory (`semantic`, `episodic`, `procedural`, or `profile`) |
|
|
64
|
+
| `recall` | Hybrid semantic + keyword retrieval across stored memories |
|
|
65
|
+
| `get_recent_memories` | Retrieve recent memories chronologically |
|
|
66
|
+
| `delete_memory` | Delete a specific memory by ID |
|
|
67
|
+
| `export_memories` | Export agent memory state |
|
|
68
|
+
| `search_temporal` | Query memories with explicit time-range weighting |
|
|
69
|
+
|
|
70
|
+
## Environment Variables
|
|
71
|
+
|
|
72
|
+
| Variable | Default | Description |
|
|
73
|
+
|----------|---------|-------------|
|
|
74
|
+
| `MEMRO_API_KEY` | — | Memro API key (`memro_sk_...`) |
|
|
75
|
+
| `MEMRO_BASE_URL` | `http://localhost:8081` | Memro backend URL (default for local self-hosting) |
|
|
76
|
+
| `MEMRO_AGENT_ID` | auto | Sovereign agent Ed25519 public key |
|
|
77
|
+
| `MEMRO_PRIVATE_KEY` | auto | Sovereign agent Ed25519 private key |
|
|
78
|
+
| `MCP_TRANSPORT` | `stdio` | `stdio` for local CLI tools, or `sse` for HTTP SSE transport |
|
|
79
|
+
| `MCP_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
Repository: [github.com/memrohq/memro](https://github.com/memrohq/memro) · Documentation: [https://memro.co](https://memro.co)
|
|
84
|
+
License: Source available under [PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0). Free for non-commercial use; commercial use through Memro Cloud or a commercial license.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
memro_mcp/__init__.py,sha256=uS4vfdgWAOou5Nl87wdBy8zLAvMhfe6ZX7LnXnaqgac,67
|
|
2
|
+
memro_mcp/sdk.py,sha256=sbJnD61Wrzrzgv_nJQ3JIwOT-QOi7t1aKbWZs-RPrqk,3332
|
|
3
|
+
memro_mcp/server.py,sha256=on-MrG4xIENtc0Zf9q1dM9J3K1lcDl_-z9Auh8n5Pbs,34050
|
|
4
|
+
memro_mcp/supermemory_tools.py,sha256=rKiF8h5eAhfuLG8oroAFvHkiWuIeP-3ZHl3X3AuFr8k,7404
|
|
5
|
+
memro_mcp-0.2.1.dist-info/licenses/LICENSE,sha256=XIYCvHdiDTF3XEd2SX0yjQUg9yxCep0DxCyRU_ImAZQ,4621
|
|
6
|
+
memro_mcp-0.2.1.dist-info/METADATA,sha256=FzixQqFqhTVrswLwiVLf-JM_VmUQt6sA7t-YnnSNDUM,2803
|
|
7
|
+
memro_mcp-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
memro_mcp-0.2.1.dist-info/entry_points.txt,sha256=OQTryRkNp9Z5eCr-6t6rQc9MniBVc9slaHn0CatN2fo,45
|
|
9
|
+
memro_mcp-0.2.1.dist-info/top_level.txt,sha256=omfVjkOhbZThkFsCCLKW2EFJcyLmfaks7AoELBNqC48,10
|
|
10
|
+
memro_mcp-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Required Notice: Copyright 2026 Memro (https://memro.co)
|
|
2
|
+
|
|
3
|
+
# PolyForm Noncommercial License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncommercial Purposes
|
|
34
|
+
|
|
35
|
+
Any noncommercial purpose is a permitted purpose.
|
|
36
|
+
|
|
37
|
+
## Personal Uses
|
|
38
|
+
|
|
39
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
40
|
+
|
|
41
|
+
## Noncommercial Organizations
|
|
42
|
+
|
|
43
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
44
|
+
|
|
45
|
+
## Fair Use
|
|
46
|
+
|
|
47
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
48
|
+
|
|
49
|
+
## No Other Rights
|
|
50
|
+
|
|
51
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
52
|
+
|
|
53
|
+
## Patent Defense
|
|
54
|
+
|
|
55
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
56
|
+
|
|
57
|
+
## Violations
|
|
58
|
+
|
|
59
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
60
|
+
|
|
61
|
+
## No Liability
|
|
62
|
+
|
|
63
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
64
|
+
|
|
65
|
+
## Definitions
|
|
66
|
+
|
|
67
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
68
|
+
|
|
69
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
70
|
+
|
|
71
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
72
|
+
|
|
73
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
74
|
+
|
|
75
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
memro_mcp
|