memcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """memcp — backend-agnostic, multi-tenant MCP memory server."""
2
+
3
+ __version__ = "0.1.0"
memcp/__main__.py ADDED
@@ -0,0 +1,38 @@
1
+ """python -m memcp entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ import uvicorn
9
+ from pydantic import ValidationError
10
+
11
+ from memcp.config import Config
12
+ from memcp.logging import setup_logging
13
+ from memcp.server import create_app
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def main() -> None:
19
+ try:
20
+ config = Config() # type: ignore[call-arg]
21
+ except ValidationError as e:
22
+ print(f"Configuration error:\n{e}", file=sys.stderr)
23
+ raise SystemExit(1) from None
24
+ setup_logging(level=config.log_level, fmt=config.log_format)
25
+
26
+ if not config.memcp_auth_tokens:
27
+ logger.warning("MEMCP_AUTH_TOKENS is unset — the MCP endpoint is UNAUTHENTICATED.")
28
+
29
+ try:
30
+ app, _backend = create_app(config)
31
+ except ValueError as e:
32
+ logger.critical("Configuration error: %s", e)
33
+ raise SystemExit(1) from None
34
+ uvicorn.run(app, host=config.host, port=config.port, log_config=None)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
memcp/auth.py ADDED
@@ -0,0 +1,156 @@
1
+ """Authentication — tenant resolution, context propagation, ASGI middleware.
2
+
3
+ Tenant identity flows: Bearer token → Resolver → ContextVar → tool handlers.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hmac
9
+ import json
10
+ import logging
11
+ from contextvars import ContextVar
12
+ from typing import Any, Protocol, runtime_checkable
13
+
14
+ from memcp.types import canonical_error
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Tenant context (per-request via contextvars)
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _tenant_var: ContextVar[str] = ContextVar("tenant_user_id")
23
+
24
+ _DEFAULT_USER = "default_user"
25
+
26
+
27
+ def get_tenant() -> str:
28
+ """Read the current request's user_id. Falls back to default in dev mode."""
29
+ return _tenant_var.get(_DEFAULT_USER)
30
+
31
+
32
+ def set_tenant(user_id: str) -> Any:
33
+ """Set the current request's user_id. Returns a reset token."""
34
+ return _tenant_var.set(user_id)
35
+
36
+
37
+ def reset_tenant(token: Any) -> None:
38
+ """Reset the contextvar to its previous value."""
39
+ _tenant_var.reset(token)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Resolver protocol
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ @runtime_checkable
48
+ class Resolver(Protocol):
49
+ async def resolve(self, token: str) -> str | None:
50
+ """Map a bearer token to a user_id. Returns None for invalid tokens (never raises)."""
51
+ ...
52
+
53
+
54
+ class StaticResolver:
55
+ """Resolves tokens from a static dict (parsed from env var)."""
56
+
57
+ def __init__(self, mapping: dict[str, str]):
58
+ self._mapping = mapping
59
+
60
+ async def resolve(self, token: str) -> str | None:
61
+ # Iterate all tokens without early return — constant-time to prevent timing oracle
62
+ matched: str | None = None
63
+ for known_token, user_id in self._mapping.items():
64
+ if hmac.compare_digest(token.encode(), known_token.encode()):
65
+ matched = user_id
66
+ return matched
67
+
68
+ @classmethod
69
+ def from_env(cls, raw: str) -> StaticResolver:
70
+ """Parse 'token1:user1,token2:user2' format."""
71
+ mapping: dict[str, str] = {}
72
+ for pair in raw.split(","):
73
+ pair = pair.strip()
74
+ if not pair:
75
+ continue
76
+ if ":" not in pair:
77
+ raise ValueError(
78
+ f"Invalid token mapping: {pair!r}. Expected format: token:user_id"
79
+ )
80
+ token, user_id = pair.split(":", 1)
81
+ token, user_id = token.strip(), user_id.strip()
82
+ if not token or not user_id:
83
+ raise ValueError(f"Empty token or user_id in mapping: {pair!r}")
84
+ mapping[token] = user_id
85
+ if not mapping:
86
+ raise ValueError("MEMCP_AUTH_TOKENS is set but contains no valid mappings")
87
+ return cls(mapping)
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # ASGI middleware
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class BearerGate:
96
+ """ASGI middleware that resolves bearer tokens to tenant identity.
97
+
98
+ Raw ASGI (not BaseHTTPMiddleware) to avoid buffering MCP streaming.
99
+ Non-HTTP scopes (lifespan) pass through.
100
+ """
101
+
102
+ def __init__(self, app: Any, resolver: Resolver | None):
103
+ self.app = app
104
+ self.resolver = resolver
105
+
106
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
107
+ if scope["type"] != "http":
108
+ await self.app(scope, receive, send)
109
+ return
110
+
111
+ if not self.resolver:
112
+ # Dev mode: no auth, default user
113
+ token = set_tenant(_DEFAULT_USER)
114
+ try:
115
+ await self.app(scope, receive, send)
116
+ finally:
117
+ reset_tenant(token)
118
+ return
119
+
120
+ headers = dict(scope.get("headers") or [])
121
+ provided = headers.get(b"authorization", b"").decode("utf-8", errors="replace")
122
+
123
+ if not provided.startswith("Bearer "):
124
+ logger.warning("Rejected request: missing Bearer prefix (path=%s)", scope.get("path"))
125
+ await self._send_401(send)
126
+ return
127
+
128
+ bearer_token = provided[7:]
129
+ user_id = await self.resolver.resolve(bearer_token)
130
+
131
+ if user_id is None:
132
+ logger.warning("Rejected request: invalid token (path=%s)", scope.get("path"))
133
+ await self._send_401(send)
134
+ return
135
+
136
+ token = set_tenant(user_id)
137
+ try:
138
+ await self.app(scope, receive, send)
139
+ finally:
140
+ reset_tenant(token)
141
+
142
+ @staticmethod
143
+ async def _send_401(send: Any) -> None:
144
+ err = canonical_error("unauthorized", "Invalid or missing token")
145
+ body = json.dumps(err).encode()
146
+ await send(
147
+ {
148
+ "type": "http.response.start",
149
+ "status": 401,
150
+ "headers": [
151
+ (b"content-type", b"application/json"),
152
+ (b"www-authenticate", b"Bearer"),
153
+ ],
154
+ }
155
+ )
156
+ await send({"type": "http.response.body", "body": body, "more_body": False})
@@ -0,0 +1,5 @@
1
+ """Memory backend adapters."""
2
+
3
+ from memcp.backend.base import MemoryBackend
4
+
5
+ __all__ = ["MemoryBackend"]
memcp/backend/base.py ADDED
@@ -0,0 +1,115 @@
1
+ """MemoryBackend ABC — @experimental, will change in v0.2 when second backend is added."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from memcp.types import (
9
+ AddResult,
10
+ EntitiesResult,
11
+ HealthStatus,
12
+ HistoryEntry,
13
+ ListResult,
14
+ Memory,
15
+ )
16
+
17
+
18
+ class MemoryBackend(ABC):
19
+ """Abstract base class for memory storage backends.
20
+
21
+ @experimental — validated against mem0 and in-memory adapters.
22
+ Will be refined when Cognee adapter is added in v0.2.
23
+ """
24
+
25
+ # --- required (universal tools) ---
26
+
27
+ @abstractmethod
28
+ async def add(
29
+ self,
30
+ user_id: str,
31
+ content: str,
32
+ *,
33
+ scope: dict[str, Any] | None = None,
34
+ metadata: dict[str, Any] | None = None,
35
+ infer: bool = True,
36
+ ) -> AddResult | list[AddResult]: ...
37
+
38
+ @abstractmethod
39
+ async def search(
40
+ self,
41
+ user_id: str,
42
+ query: str,
43
+ *,
44
+ scope: dict[str, Any] | None = None,
45
+ limit: int = 10,
46
+ threshold: float = 0.0,
47
+ ) -> list[Memory]: ...
48
+
49
+ @abstractmethod
50
+ async def delete(self, user_id: str, memory_id: str) -> bool:
51
+ """Raises MemoryAPIError(404) if not found or not owned by user_id."""
52
+ ...
53
+
54
+ @abstractmethod
55
+ async def delete_all(self, user_id: str, scope: dict[str, Any]) -> int | None:
56
+ """Returns count of deleted memories, or None if backend doesn't report counts."""
57
+ ...
58
+
59
+ @abstractmethod
60
+ async def health(self) -> HealthStatus: ...
61
+
62
+ @abstractmethod
63
+ def capabilities(self) -> set[str]: ...
64
+
65
+ @abstractmethod
66
+ def scope_keys(self) -> list[str]: ...
67
+
68
+ # --- optional (declared in capabilities()) ---
69
+
70
+ async def get(self, user_id: str, memory_id: str) -> Memory | None:
71
+ """Returns None if not found or not owned by user_id."""
72
+ raise NotImplementedError
73
+
74
+ async def update(
75
+ self,
76
+ user_id: str,
77
+ memory_id: str,
78
+ content: str,
79
+ *,
80
+ metadata: dict[str, Any] | None = None,
81
+ ) -> Memory:
82
+ """Raises MemoryAPIError(404) if not found or not owned by user_id."""
83
+ raise NotImplementedError
84
+
85
+ async def list_memories(
86
+ self,
87
+ user_id: str,
88
+ *,
89
+ scope: dict[str, Any] | None = None,
90
+ limit: int = 100,
91
+ cursor: str | None = None,
92
+ ) -> ListResult:
93
+ raise NotImplementedError
94
+
95
+ async def history(self, user_id: str, memory_id: str) -> list[HistoryEntry]:
96
+ """Raises MemoryAPIError(404) if not found or not owned by user_id.
97
+
98
+ Note: action strings are backend-specific (e.g. "created"/"updated" vs
99
+ "add"/"update"). Normalize in v0.2 when the Protocol stabilizes.
100
+ """
101
+ raise NotImplementedError
102
+
103
+ async def entities(
104
+ self,
105
+ user_id: str,
106
+ *,
107
+ scope: dict[str, Any] | None = None,
108
+ limit: int = 100,
109
+ ) -> EntitiesResult:
110
+ raise NotImplementedError
111
+
112
+ # --- lifecycle ---
113
+
114
+ async def close(self) -> None: # noqa: B027
115
+ """Clean up resources. Called on shutdown."""
@@ -0,0 +1,261 @@
1
+ """In-memory backend adapter — for conformance tests, dev mode, and demos.
2
+
3
+ Stores memories in plain dicts. No persistence, no extraction, no vector search.
4
+ Search uses substring matching on content as a trivial approximation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+ from memcp.types import (
14
+ AddResult,
15
+ EntitiesResult,
16
+ HealthStatus,
17
+ HistoryEntry,
18
+ ListResult,
19
+ Memory,
20
+ MemoryAPIError,
21
+ paginate,
22
+ reject_nested_filters,
23
+ )
24
+
25
+ from .base import MemoryBackend
26
+
27
+
28
+ class InMemoryBackend(MemoryBackend):
29
+ """Trivial in-memory implementation of the MemoryBackend protocol."""
30
+
31
+ def __init__(self) -> None:
32
+ # {memory_id: {user_id, content, scope, metadata, created_at, updated_at}}
33
+ self._store: dict[str, dict[str, Any]] = {}
34
+ # {memory_id: [HistoryEntry, ...]}
35
+ self._history: dict[str, list[dict[str, Any]]] = {}
36
+
37
+ # --- required ---
38
+
39
+ async def add(
40
+ self,
41
+ user_id: str,
42
+ content: str,
43
+ *,
44
+ scope: dict[str, Any] | None = None,
45
+ metadata: dict[str, Any] | None = None,
46
+ infer: bool = True,
47
+ ) -> list[AddResult]:
48
+ if scope:
49
+ reject_nested_filters(scope)
50
+ memory_id = str(uuid.uuid4())
51
+ now = datetime.now(UTC).isoformat()
52
+ self._store[memory_id] = {
53
+ "user_id": user_id,
54
+ "content": content,
55
+ "scope": scope or {},
56
+ "metadata": metadata or {},
57
+ "created_at": now,
58
+ "updated_at": None,
59
+ }
60
+ self._history[memory_id] = [
61
+ {
62
+ "action": "created",
63
+ "timestamp": now,
64
+ "content_before": None,
65
+ "content_after": content,
66
+ }
67
+ ]
68
+ return [AddResult(id=memory_id, status="ready", created_at=now)]
69
+
70
+ async def search(
71
+ self,
72
+ user_id: str,
73
+ query: str,
74
+ *,
75
+ scope: dict[str, Any] | None = None,
76
+ limit: int = 10,
77
+ threshold: float = 0.0,
78
+ ) -> list[Memory]:
79
+ if scope:
80
+ reject_nested_filters(scope)
81
+ results = []
82
+ query_lower = query.lower()
83
+ for mid, entry in self._store.items():
84
+ if entry["user_id"] != user_id:
85
+ continue
86
+ if scope:
87
+ entry_scope = entry.get("scope", {})
88
+ if not all(entry_scope.get(k) == v for k, v in scope.items()):
89
+ continue
90
+ content_lower = entry["content"].lower()
91
+ # Trivial relevance: count query word matches
92
+ words = query_lower.split()
93
+ matches = sum(1 for w in words if w in content_lower)
94
+ if matches == 0 and query_lower not in content_lower:
95
+ continue
96
+ score = matches / max(len(words), 1)
97
+ results.append((score, mid, entry))
98
+ results.sort(key=lambda x: x[0], reverse=True)
99
+ return [
100
+ Memory(
101
+ id=mid,
102
+ content=entry["content"],
103
+ score=score,
104
+ scope=entry.get("scope", {}),
105
+ metadata=entry.get("metadata", {}),
106
+ created_at=entry["created_at"],
107
+ updated_at=entry.get("updated_at"),
108
+ )
109
+ for score, mid, entry in results[:limit]
110
+ ]
111
+
112
+ async def delete(self, user_id: str, memory_id: str) -> bool:
113
+ entry = self._store.get(memory_id)
114
+ if entry is None:
115
+ raise MemoryAPIError(404, "Not found")
116
+ if entry["user_id"] != user_id:
117
+ raise MemoryAPIError(404, "Not found")
118
+ del self._store[memory_id]
119
+ self._history.pop(memory_id, None)
120
+ return True
121
+
122
+ async def delete_all(self, user_id: str, scope: dict[str, Any]) -> int:
123
+ reject_nested_filters(scope)
124
+ to_delete = []
125
+ for mid, entry in self._store.items():
126
+ if entry["user_id"] != user_id:
127
+ continue
128
+ entry_scope = entry.get("scope", {})
129
+ if all(entry_scope.get(k) == v for k, v in scope.items()):
130
+ to_delete.append(mid)
131
+ for mid in to_delete:
132
+ del self._store[mid]
133
+ self._history.pop(mid, None)
134
+ return len(to_delete)
135
+
136
+ async def health(self) -> HealthStatus:
137
+ return HealthStatus(status="healthy", backend="in_memory", latency_ms=0.0)
138
+
139
+ def capabilities(self) -> set[str]:
140
+ return {
141
+ "get_memory",
142
+ "update_memory",
143
+ "list_memories",
144
+ "memory_history",
145
+ "memory_entities",
146
+ }
147
+
148
+ def scope_keys(self) -> list[str]:
149
+ return ["agent_id", "run_id"]
150
+
151
+ # --- optional ---
152
+
153
+ async def get(self, user_id: str, memory_id: str) -> Memory | None:
154
+ entry = self._store.get(memory_id)
155
+ if entry is None or entry["user_id"] != user_id:
156
+ return None
157
+ return Memory(
158
+ id=memory_id,
159
+ content=entry["content"],
160
+ scope=entry.get("scope", {}),
161
+ metadata=entry.get("metadata", {}),
162
+ created_at=entry["created_at"],
163
+ updated_at=entry.get("updated_at"),
164
+ )
165
+
166
+ async def update(
167
+ self,
168
+ user_id: str,
169
+ memory_id: str,
170
+ content: str,
171
+ *,
172
+ metadata: dict[str, Any] | None = None,
173
+ ) -> Memory:
174
+ entry = self._store.get(memory_id)
175
+ if entry is None or entry["user_id"] != user_id:
176
+ raise MemoryAPIError(404, "Not found")
177
+ now = datetime.now(UTC).isoformat()
178
+ old_content = entry["content"]
179
+ entry["content"] = content
180
+ entry["updated_at"] = now
181
+ if metadata is not None:
182
+ entry["metadata"] = metadata
183
+ if memory_id in self._history:
184
+ self._history[memory_id].append(
185
+ {
186
+ "action": "updated",
187
+ "timestamp": now,
188
+ "content_before": old_content,
189
+ "content_after": content,
190
+ }
191
+ )
192
+ return Memory(
193
+ id=memory_id,
194
+ content=content,
195
+ scope=entry.get("scope", {}),
196
+ metadata=entry.get("metadata", {}),
197
+ created_at=entry["created_at"],
198
+ updated_at=now,
199
+ )
200
+
201
+ async def list_memories(
202
+ self,
203
+ user_id: str,
204
+ *,
205
+ scope: dict[str, Any] | None = None,
206
+ limit: int = 100,
207
+ cursor: str | None = None,
208
+ ) -> ListResult:
209
+ memories = []
210
+ for mid, entry in self._store.items():
211
+ if entry["user_id"] != user_id:
212
+ continue
213
+ if scope:
214
+ entry_scope = entry.get("scope", {})
215
+ if not all(entry_scope.get(k) == v for k, v in scope.items()):
216
+ continue
217
+ memories.append(
218
+ Memory(
219
+ id=mid,
220
+ content=entry["content"],
221
+ scope=entry.get("scope", {}),
222
+ metadata=entry.get("metadata", {}),
223
+ created_at=entry["created_at"],
224
+ updated_at=entry.get("updated_at"),
225
+ )
226
+ )
227
+ return paginate(memories, cursor, limit)
228
+
229
+ async def history(self, user_id: str, memory_id: str) -> list[HistoryEntry]:
230
+ entry = self._store.get(memory_id)
231
+ if entry is None or entry["user_id"] != user_id:
232
+ raise MemoryAPIError(404, "Not found")
233
+ raw = self._history.get(memory_id, [])
234
+ return [
235
+ HistoryEntry(
236
+ action=h["action"],
237
+ timestamp=h["timestamp"],
238
+ content_before=h.get("content_before"),
239
+ content_after=h.get("content_after"),
240
+ )
241
+ for h in raw
242
+ ]
243
+
244
+ async def entities(
245
+ self,
246
+ user_id: str,
247
+ *,
248
+ scope: dict[str, Any] | None = None,
249
+ limit: int = 100,
250
+ ) -> EntitiesResult:
251
+ count = sum(1 for e in self._store.values() if e["user_id"] == user_id)
252
+ if count == 0:
253
+ return EntitiesResult(entities=[], relationships=[])
254
+ return EntitiesResult(
255
+ entities=[{"id": user_id, "type": "user", "total_memories": count}],
256
+ relationships=[],
257
+ )
258
+
259
+ async def close(self) -> None:
260
+ self._store.clear()
261
+ self._history.clear()