cortexdb-mcp 0.7.1__tar.gz → 0.7.2__tar.gz
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.
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/PKG-INFO +1 -1
- cortexdb_mcp-0.7.2/cortexdb_mcp/check_call.py +239 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/cortexdb_mcp/server.py +1701 -1669
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/pyproject.toml +26 -26
- cortexdb_mcp-0.7.2/tests/test_check_call.py +180 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/.gitignore +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/Dockerfile +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/README.md +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/cortexdb_mcp/__init__.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/cortexdb_mcp/__main__.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/cortexdb_mcp/api.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/cortexdb_mcp/config.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/cortexdb_mcp/insights.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/cortexdb_mcp/render.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/tests/__init__.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/tests/test_insights.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/tests/test_integration.py +0 -0
- {cortexdb_mcp-0.7.1 → cortexdb_mcp-0.7.2}/tests/test_server.py +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Validate a CortexDB call BEFORE the agent writes it.
|
|
2
|
+
|
|
3
|
+
KAN-167. Documentation search only helps an agent that knows it is unsure.
|
|
4
|
+
The failure that actually produces broken integrations is the confident
|
|
5
|
+
guess: the model writes `client.remember(...)` or `POST /v1/memories`, gets
|
|
6
|
+
a plausible-looking error, and invents a workaround. Nothing in that loop
|
|
7
|
+
tells it the real call exists under a different name.
|
|
8
|
+
|
|
9
|
+
So the check runs the other way round. The agent pastes what it is ABOUT to
|
|
10
|
+
write and is told whether it is valid, with the correction when it is not.
|
|
11
|
+
|
|
12
|
+
Pure functions over a static contract, deliberately: no network, no
|
|
13
|
+
credentials, no server. A validator an agent has to authenticate to is one
|
|
14
|
+
it will skip, and this has to be cheaper than guessing to get used at all.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
DOCS = "https://cortexdb.ai/docs"
|
|
25
|
+
MANIFEST = "https://cortexdb.ai/agent/manifest.json"
|
|
26
|
+
|
|
27
|
+
#: The v1 surface an integrator touches, and the required body fields.
|
|
28
|
+
#: Intentionally the COMMON surface, not all 117 routes: a validator that
|
|
29
|
+
#: lists everything cannot say "you meant this one".
|
|
30
|
+
ROUTES: dict[tuple[str, str], dict[str, Any]] = {
|
|
31
|
+
("POST", "/v1/experience"): {
|
|
32
|
+
"purpose": "write one memory",
|
|
33
|
+
"required": ["scope", "content"],
|
|
34
|
+
"notes": "Use ?wait=indexed when the next step recalls it.",
|
|
35
|
+
},
|
|
36
|
+
("POST", "/v1/experience/bulk"): {
|
|
37
|
+
"purpose": "write many memories",
|
|
38
|
+
"required": ["items"],
|
|
39
|
+
"notes": "Each item carries its own scope; there is no top-level scope.",
|
|
40
|
+
},
|
|
41
|
+
("POST", "/v1/recall"): {
|
|
42
|
+
"purpose": "retrieve a context pack",
|
|
43
|
+
"required": ["scope", "query"],
|
|
44
|
+
"notes": "view is one of raw | granular | holistic | structured | descend.",
|
|
45
|
+
},
|
|
46
|
+
("POST", "/v1/answer"): {"purpose": "recall + LLM answer", "required": ["scope", "query"]},
|
|
47
|
+
("POST", "/v1/compose"): {"purpose": "markdown document from memory", "required": ["scope", "query"]},
|
|
48
|
+
("POST", "/v1/forget"): {
|
|
49
|
+
"purpose": "delete with audit",
|
|
50
|
+
"required": ["scope"],
|
|
51
|
+
"notes": "A selector with confirm_all=true is refused: confirm_all "
|
|
52
|
+
"authorizes a SCOPE-WIDE erase, a selector narrows it. Pick one.",
|
|
53
|
+
},
|
|
54
|
+
("POST", "/v1/forget/preview"): {"purpose": "dry-run a forget", "required": ["scope"]},
|
|
55
|
+
("GET", "/v1/events"): {"purpose": "list raw events", "required": []},
|
|
56
|
+
("POST", "/v1/auth/signup"): {"purpose": "anonymous free-tier identity", "required": []},
|
|
57
|
+
("GET", "/v1/auth/whoami"): {"purpose": "caller + capabilities", "required": []},
|
|
58
|
+
("GET", "/v1/admin/health"): {"purpose": "liveness", "required": []},
|
|
59
|
+
("GET", "/v1/admin/ready"): {"purpose": "readiness + capability canaries", "required": []},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#: Paths agents reach for that do not exist, and what they meant.
|
|
63
|
+
PATH_ALIASES = {
|
|
64
|
+
"/v1/memories": "/v1/experience",
|
|
65
|
+
"/v1/memory": "/v1/experience",
|
|
66
|
+
"/v1/remember": "/v1/experience",
|
|
67
|
+
"/v1/store": "/v1/experience",
|
|
68
|
+
"/v1/add": "/v1/experience",
|
|
69
|
+
"/v1/search": "/v1/recall",
|
|
70
|
+
"/v1/query": "/v1/recall",
|
|
71
|
+
"/v1/retrieve": "/v1/recall",
|
|
72
|
+
"/v1/ask": "/v1/answer",
|
|
73
|
+
"/v1/delete": "/v1/forget",
|
|
74
|
+
"/v1/health": "/v1/admin/health",
|
|
75
|
+
"/memories": "/v1/experience",
|
|
76
|
+
"/recall": "/v1/recall",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#: SDK methods, mirroring the guidance the SDKs themselves now raise.
|
|
80
|
+
SDK_METHODS = {
|
|
81
|
+
"experience", "experience_bulk", "recall", "answer", "compose", "forget",
|
|
82
|
+
"forget_preview", "events", "episodes", "facts", "beliefs", "whoami",
|
|
83
|
+
"signup", "upload_blob", "erase", "erasure_preview",
|
|
84
|
+
}
|
|
85
|
+
SDK_ALIASES = {
|
|
86
|
+
"remember": "experience", "store": "experience", "add": "experience",
|
|
87
|
+
"add_memory": "experience", "write": "experience", "save": "experience",
|
|
88
|
+
"retrieve": "recall", "search": "recall", "query": "recall",
|
|
89
|
+
"get_memories": "recall", "ask": "answer", "chat": "answer",
|
|
90
|
+
"delete": "forget", "remove": "forget", "delete_memory": "forget",
|
|
91
|
+
"bulk": "experience_bulk", "upload": "upload_blob", "me": "whoami",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Verdict:
|
|
97
|
+
valid: bool
|
|
98
|
+
call: str
|
|
99
|
+
problems: list[str] = field(default_factory=list)
|
|
100
|
+
correction: str | None = None
|
|
101
|
+
notes: list[str] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, Any]:
|
|
104
|
+
return {
|
|
105
|
+
"schema": "cortexdb.check_call/v1",
|
|
106
|
+
"valid": self.valid,
|
|
107
|
+
"call": self.call,
|
|
108
|
+
"problems": self.problems,
|
|
109
|
+
"correction": self.correction,
|
|
110
|
+
"notes": self.notes,
|
|
111
|
+
"docs": DOCS,
|
|
112
|
+
"manifest": MANIFEST,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_scope(scope: str) -> list[str]:
|
|
117
|
+
"""Scope grammar: colon-delimited key:value segments joined by '/'.
|
|
118
|
+
|
|
119
|
+
The single most common integration bug, and silent in both directions -
|
|
120
|
+
a bad scope writes where nobody reads and recalls nothing.
|
|
121
|
+
"""
|
|
122
|
+
problems = []
|
|
123
|
+
if not scope:
|
|
124
|
+
return ["scope is empty"]
|
|
125
|
+
for i, segment in enumerate(scope.split("/"), 1):
|
|
126
|
+
if not segment:
|
|
127
|
+
problems.append(f"scope segment {i} is empty (double slash?)")
|
|
128
|
+
elif ":" not in segment:
|
|
129
|
+
problems.append(
|
|
130
|
+
f"scope segment {i} ({segment!r}) has no ':' delimiter - "
|
|
131
|
+
f"segments are key:value, e.g. 'user:{segment}'. "
|
|
132
|
+
f"The server rejects this with 422 INVALID_BODY."
|
|
133
|
+
)
|
|
134
|
+
return problems
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_http(method: str, path: str, body: dict[str, Any] | None = None) -> Verdict:
|
|
138
|
+
"""Validate an HTTP call against the v1 surface."""
|
|
139
|
+
method = (method or "").strip().upper()
|
|
140
|
+
path = (path or "").strip()
|
|
141
|
+
if "?" in path:
|
|
142
|
+
path = path.split("?", 1)[0]
|
|
143
|
+
path = "/" + path.strip("/") if path else ""
|
|
144
|
+
call = f"{method} {path}"
|
|
145
|
+
body = body or {}
|
|
146
|
+
|
|
147
|
+
if (method, path) not in ROUTES:
|
|
148
|
+
alias = PATH_ALIASES.get(path)
|
|
149
|
+
if alias:
|
|
150
|
+
spec = ROUTES.get(("POST", alias)) or ROUTES.get(("GET", alias)) or {}
|
|
151
|
+
correct_method = "POST" if ("POST", alias) in ROUTES else "GET"
|
|
152
|
+
return Verdict(
|
|
153
|
+
valid=False,
|
|
154
|
+
call=call,
|
|
155
|
+
problems=[f"{path} does not exist."],
|
|
156
|
+
correction=f"{correct_method} {alias} ({spec.get('purpose', '')})",
|
|
157
|
+
notes=[n for n in [spec.get("notes")] if n],
|
|
158
|
+
)
|
|
159
|
+
known = sorted({p for _, p in ROUTES})
|
|
160
|
+
close = difflib.get_close_matches(path, known, n=3, cutoff=0.5)
|
|
161
|
+
problems = [f"{path} is not a documented v1 route."]
|
|
162
|
+
correction = None
|
|
163
|
+
if close:
|
|
164
|
+
correction = " ".join(close)
|
|
165
|
+
# A known path with the wrong verb is a distinct, commoner mistake.
|
|
166
|
+
for m, p in ROUTES:
|
|
167
|
+
if p == path and m != method:
|
|
168
|
+
problems = [f"{path} exists but not for {method}."]
|
|
169
|
+
correction = f"{m} {path}"
|
|
170
|
+
close = []
|
|
171
|
+
break
|
|
172
|
+
return Verdict(valid=False, call=call, problems=problems, correction=correction,
|
|
173
|
+
notes=[] if close or correction else [f"See {MANIFEST} for the full surface."])
|
|
174
|
+
|
|
175
|
+
spec = ROUTES[(method, path)]
|
|
176
|
+
problems: list[str] = []
|
|
177
|
+
for required in spec.get("required", []):
|
|
178
|
+
if required not in body:
|
|
179
|
+
problems.append(f"missing required field: {required!r}")
|
|
180
|
+
if "scope" in body and isinstance(body["scope"], str):
|
|
181
|
+
problems.extend(_check_scope(body["scope"]))
|
|
182
|
+
# The one cross-field rule the server enforces and agents keep hitting.
|
|
183
|
+
if path.startswith("/v1/forget"):
|
|
184
|
+
selectors = [k for k in ("memory_ids", "about_subject", "about_entity", "predicate")
|
|
185
|
+
if body.get(k)]
|
|
186
|
+
if selectors and body.get("confirm_all"):
|
|
187
|
+
problems.append(
|
|
188
|
+
f"selector ({', '.join(selectors)}) combined with confirm_all=true is "
|
|
189
|
+
f"refused (AMBIGUOUS_SELECTOR_CONFIRM_ALL). confirm_all authorizes a "
|
|
190
|
+
f"SCOPE-WIDE erase; a selector narrows it. Use confirm_all=false with "
|
|
191
|
+
f"the selector, or drop the selector for a scope-wide forget."
|
|
192
|
+
)
|
|
193
|
+
notes = [n for n in [spec.get("notes")] if n]
|
|
194
|
+
return Verdict(valid=not problems, call=call, problems=problems, notes=notes)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_sdk(snippet: str) -> Verdict:
|
|
198
|
+
"""Validate an SDK call written as `client.method(...)`."""
|
|
199
|
+
snippet = (snippet or "").strip()
|
|
200
|
+
match = re.search(r"\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(", snippet)
|
|
201
|
+
if not match:
|
|
202
|
+
return Verdict(
|
|
203
|
+
valid=False, call=snippet,
|
|
204
|
+
problems=["could not find a method call of the form client.method(...)"],
|
|
205
|
+
correction=None,
|
|
206
|
+
)
|
|
207
|
+
name = match.group(1)
|
|
208
|
+
if name in SDK_METHODS:
|
|
209
|
+
return Verdict(valid=True, call=f".{name}()")
|
|
210
|
+
alias = SDK_ALIASES.get(name)
|
|
211
|
+
if alias:
|
|
212
|
+
return Verdict(
|
|
213
|
+
valid=False, call=f".{name}()",
|
|
214
|
+
problems=[f".{name}() does not exist."],
|
|
215
|
+
correction=f".{alias}() - CortexDB calls that operation {alias!r}.",
|
|
216
|
+
)
|
|
217
|
+
close = difflib.get_close_matches(name, sorted(SDK_METHODS), n=3, cutoff=0.6)
|
|
218
|
+
return Verdict(
|
|
219
|
+
valid=False, call=f".{name}()",
|
|
220
|
+
problems=[f".{name}() does not exist."],
|
|
221
|
+
correction=" ".join(f".{c}()" for c in close) if close else None,
|
|
222
|
+
notes=[] if close else [f"Available: {', '.join(sorted(SDK_METHODS))}"],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def check(call: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
227
|
+
"""Entry point: accepts either an HTTP call or an SDK snippet."""
|
|
228
|
+
text = (call or "").strip()
|
|
229
|
+
if not text:
|
|
230
|
+
return Verdict(valid=False, call="", problems=["nothing to check"]).to_dict()
|
|
231
|
+
|
|
232
|
+
http = re.match(r"^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)", text, re.I)
|
|
233
|
+
if http:
|
|
234
|
+
return check_http(http.group(1), http.group(2), body).to_dict()
|
|
235
|
+
if text.startswith("/"):
|
|
236
|
+
# A bare path is almost always meant as a request; guess POST rather
|
|
237
|
+
# than refusing, and the verb correction covers a wrong guess.
|
|
238
|
+
return check_http("POST", text, body).to_dict()
|
|
239
|
+
return check_sdk(text).to_dict()
|