ctxstore-mcp 1.0.0__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.
- ctxstore_mcp-1.0.0/LICENSE +21 -0
- ctxstore_mcp-1.0.0/PKG-INFO +27 -0
- ctxstore_mcp-1.0.0/README.md +1 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp/__init__.py +15 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp/__main__.py +4 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp/auth.py +120 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp/server.py +303 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp/setup_cli.py +156 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp.egg-info/PKG-INFO +27 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp.egg-info/SOURCES.txt +14 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp.egg-info/dependency_links.txt +1 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp.egg-info/entry_points.txt +3 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp.egg-info/requires.txt +2 -0
- ctxstore_mcp-1.0.0/ctxstore_mcp.egg-info/top_level.txt +1 -0
- ctxstore_mcp-1.0.0/pyproject.toml +39 -0
- ctxstore_mcp-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ctxstore.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctxstore-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Persistent AI memory via MCP. Give your AI agent long-term memory in 30 seconds.
|
|
5
|
+
Author-email: "ctxstore.ai" <hello@ctxstore.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ctxstore.ai
|
|
8
|
+
Project-URL: Documentation, https://ctxstore.ai/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/ctxstore-ai/ctxstore-mcp
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/ctxstore-ai/ctxstore-mcp/issues
|
|
11
|
+
Keywords: mcp,ai,memory,context,vector,llm,claude,cursor
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mcp>=1.0.0
|
|
24
|
+
Requires-Dist: httpx>=0.25.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# ctxstore-mcp
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ctxstore-mcp
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctxstore-mcp — Persistent AI memory via MCP.
|
|
3
|
+
|
|
4
|
+
Thin MCP server that proxies to the ctxstore.ai API.
|
|
5
|
+
All embedding and storage happens server-side.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from .server import main as _main
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
"""CLI entry point."""
|
|
15
|
+
asyncio.run(_main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctxstore credential management — auto-provisioning and credential caching.
|
|
3
|
+
|
|
4
|
+
Flow:
|
|
5
|
+
1. Check TENANT_API_KEY env var (always wins)
|
|
6
|
+
2. Check ~/.ctxstore/credentials.json
|
|
7
|
+
3. POST /api/v1/provision to get a free key, save it
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("ctxstore.auth")
|
|
21
|
+
|
|
22
|
+
CREDENTIALS_FILE = Path.home() / ".ctxstore" / "credentials.json"
|
|
23
|
+
CTXSTORE_URL = os.getenv("CTXSTORE_URL", "https://ctxstore.ai").rstrip("/")
|
|
24
|
+
PROVISION_URL = f"{CTXSTORE_URL}/api/v1/provision"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_credentials() -> Optional[dict]:
|
|
28
|
+
"""Load saved credentials from disk. Returns None if not found or invalid."""
|
|
29
|
+
if not CREDENTIALS_FILE.exists():
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
with open(CREDENTIALS_FILE) as f:
|
|
33
|
+
data = json.load(f)
|
|
34
|
+
if data.get("api_key"):
|
|
35
|
+
return data
|
|
36
|
+
except (json.JSONDecodeError, OSError):
|
|
37
|
+
pass
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_credentials(api_key: str, tenant_id: str = "") -> None:
|
|
42
|
+
"""Save credentials to ~/.ctxstore/credentials.json with 600 permissions."""
|
|
43
|
+
CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
data = {
|
|
45
|
+
"api_key": api_key,
|
|
46
|
+
"tenant_id": tenant_id,
|
|
47
|
+
"provisioned_at": datetime.now(timezone.utc).isoformat(),
|
|
48
|
+
}
|
|
49
|
+
with open(CREDENTIALS_FILE, "w") as f:
|
|
50
|
+
json.dump(data, f, indent=2)
|
|
51
|
+
f.write("\n")
|
|
52
|
+
CREDENTIALS_FILE.chmod(0o600)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def provision() -> str:
|
|
56
|
+
"""
|
|
57
|
+
Call the ctxstore.ai provision endpoint to get a free API key.
|
|
58
|
+
Saves credentials to disk and prints a friendly first-run message.
|
|
59
|
+
Returns the api_key string.
|
|
60
|
+
"""
|
|
61
|
+
print(
|
|
62
|
+
"\n✦ ctxstore.ai: provisioning your free memory account...",
|
|
63
|
+
file=sys.stderr,
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
resp = httpx.post(
|
|
67
|
+
PROVISION_URL,
|
|
68
|
+
json={"source": "ctxstore-mcp"},
|
|
69
|
+
timeout=15.0,
|
|
70
|
+
)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
data = resp.json()
|
|
73
|
+
except httpx.HTTPError as e:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
f"Could not provision ctxstore.ai account: {e}\n"
|
|
76
|
+
f"Set TENANT_API_KEY manually or visit {CTXSTORE_URL}"
|
|
77
|
+
) from e
|
|
78
|
+
|
|
79
|
+
api_key = data.get("api_key", "")
|
|
80
|
+
tenant_id = data.get("tenant_id", "")
|
|
81
|
+
|
|
82
|
+
if not api_key:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"Provisioning response missing api_key. "
|
|
85
|
+
f"Visit {CTXSTORE_URL} to get your key."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
save_credentials(api_key, tenant_id)
|
|
89
|
+
print(
|
|
90
|
+
f"✓ ctxstore.ai: account provisioned! Key saved to {CREDENTIALS_FILE}",
|
|
91
|
+
file=sys.stderr,
|
|
92
|
+
)
|
|
93
|
+
return api_key
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_or_provision() -> str:
|
|
97
|
+
"""
|
|
98
|
+
Return the API key, auto-provisioning if needed.
|
|
99
|
+
|
|
100
|
+
Priority:
|
|
101
|
+
1. TENANT_API_KEY environment variable
|
|
102
|
+
2. ~/.ctxstore/credentials.json
|
|
103
|
+
3. Auto-provision via ctxstore.ai (free, no signup)
|
|
104
|
+
"""
|
|
105
|
+
# 1. Env var always wins
|
|
106
|
+
env_key = os.getenv("TENANT_API_KEY", "").strip()
|
|
107
|
+
if env_key:
|
|
108
|
+
return env_key
|
|
109
|
+
|
|
110
|
+
# 2. Saved credentials
|
|
111
|
+
creds = load_credentials()
|
|
112
|
+
if creds:
|
|
113
|
+
return creds["api_key"]
|
|
114
|
+
|
|
115
|
+
# 3. Auto-provision
|
|
116
|
+
return provision()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Alias for server.py import
|
|
120
|
+
resolve_api_key = get_or_provision
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctxstore MCP Server — thin client that proxies to ctxstore.ai API.
|
|
3
|
+
|
|
4
|
+
All embedding generation and vector storage happens server-side.
|
|
5
|
+
This package just translates MCP tool calls to HTTP API calls.
|
|
6
|
+
|
|
7
|
+
Environment variables:
|
|
8
|
+
TENANT_API_KEY — Your ctxstore.ai API key (required)
|
|
9
|
+
CTXSTORE_URL — API base URL (default: https://ctxstore.ai)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from mcp.server import Server
|
|
19
|
+
from mcp.server.stdio import stdio_server
|
|
20
|
+
from mcp.types import TextContent, Tool
|
|
21
|
+
|
|
22
|
+
from .auth import resolve_api_key
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("ctxstore.mcp")
|
|
25
|
+
|
|
26
|
+
BASE_URL = os.getenv("CTXSTORE_URL", "https://ctxstore.ai").rstrip("/")
|
|
27
|
+
|
|
28
|
+
# Resolved lazily on first use so startup auto-provision prints before the
|
|
29
|
+
# server enters the stdio event loop.
|
|
30
|
+
_API_KEY: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_api_key() -> str:
|
|
34
|
+
global _API_KEY
|
|
35
|
+
if _API_KEY is None:
|
|
36
|
+
_API_KEY = resolve_api_key()
|
|
37
|
+
return _API_KEY
|
|
38
|
+
|
|
39
|
+
app = Server("ctxstore")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _headers() -> dict:
|
|
43
|
+
return {
|
|
44
|
+
"Authorization": f"Bearer {_get_api_key()}",
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"User-Agent": "ctxstore-mcp/1.0.0",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _api(method: str, path: str, json: dict = None) -> dict:
|
|
51
|
+
"""Make an authenticated API call to ctxstore.ai."""
|
|
52
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
53
|
+
resp = await client.request(
|
|
54
|
+
method,
|
|
55
|
+
f"{BASE_URL}{path}",
|
|
56
|
+
headers=_headers(),
|
|
57
|
+
json=json,
|
|
58
|
+
)
|
|
59
|
+
if resp.status_code == 401:
|
|
60
|
+
return {"error": "Invalid API key. Check your TENANT_API_KEY."}
|
|
61
|
+
if resp.status_code == 429:
|
|
62
|
+
return {"error": "Rate limit reached. Upgrade your plan at ctxstore.ai."}
|
|
63
|
+
if resp.status_code >= 400:
|
|
64
|
+
try:
|
|
65
|
+
return resp.json()
|
|
66
|
+
except Exception:
|
|
67
|
+
return {"error": f"API error: {resp.status_code}"}
|
|
68
|
+
return resp.json()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.list_tools()
|
|
72
|
+
async def list_tools() -> list[Tool]:
|
|
73
|
+
return [
|
|
74
|
+
Tool(
|
|
75
|
+
name="search_context",
|
|
76
|
+
description=(
|
|
77
|
+
"Search memory — all ingested conversations and sessions. "
|
|
78
|
+
"Returns semantically relevant context with temporal weighting "
|
|
79
|
+
"(recent = higher rank)."
|
|
80
|
+
),
|
|
81
|
+
inputSchema={
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"query": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"description": "Natural language search query",
|
|
87
|
+
},
|
|
88
|
+
"top_k": {
|
|
89
|
+
"type": "integer",
|
|
90
|
+
"description": "Number of results (default 20, max 100)",
|
|
91
|
+
"default": 20,
|
|
92
|
+
},
|
|
93
|
+
"source": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Filter by source: 'chatgpt' or 'claude'",
|
|
96
|
+
"enum": ["chatgpt", "claude"],
|
|
97
|
+
},
|
|
98
|
+
"days_back": {
|
|
99
|
+
"type": "integer",
|
|
100
|
+
"description": "Only search within last N days",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
"required": ["query"],
|
|
104
|
+
},
|
|
105
|
+
),
|
|
106
|
+
Tool(
|
|
107
|
+
name="search_facts",
|
|
108
|
+
description=(
|
|
109
|
+
"Search extracted facts, preferences, and decisions. "
|
|
110
|
+
"Facts are tagged by category: preference, decision, identity, "
|
|
111
|
+
"technical, relationship. Permanent facts bypass temporal decay."
|
|
112
|
+
),
|
|
113
|
+
inputSchema={
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {
|
|
116
|
+
"query": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"description": "What to search for",
|
|
119
|
+
},
|
|
120
|
+
"category": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"description": "Filter by category",
|
|
123
|
+
"enum": ["preference", "decision", "identity", "technical", "relationship"],
|
|
124
|
+
},
|
|
125
|
+
"top_k": {
|
|
126
|
+
"type": "integer",
|
|
127
|
+
"description": "Number of results",
|
|
128
|
+
"default": 10,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
"required": ["query"],
|
|
132
|
+
},
|
|
133
|
+
),
|
|
134
|
+
Tool(
|
|
135
|
+
name="store_fact",
|
|
136
|
+
description=(
|
|
137
|
+
"Store a new fact for permanent retention. Use this when the user "
|
|
138
|
+
"states a preference, makes a decision, or shares information that "
|
|
139
|
+
"should persist across sessions."
|
|
140
|
+
),
|
|
141
|
+
inputSchema={
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"text": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"description": "The fact to store",
|
|
147
|
+
},
|
|
148
|
+
"category": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"description": "Fact category",
|
|
151
|
+
"enum": ["preference", "decision", "identity", "technical", "relationship"],
|
|
152
|
+
},
|
|
153
|
+
"is_permanent": {
|
|
154
|
+
"type": "boolean",
|
|
155
|
+
"description": "Whether this fact should bypass temporal decay",
|
|
156
|
+
"default": True,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
"required": ["text", "category"],
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
Tool(
|
|
163
|
+
name="delete_fact",
|
|
164
|
+
description="Delete a stored fact by its ID.",
|
|
165
|
+
inputSchema={
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"fact_id": {
|
|
169
|
+
"type": "string",
|
|
170
|
+
"description": "The UUID of the fact to delete",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
"required": ["fact_id"],
|
|
174
|
+
},
|
|
175
|
+
),
|
|
176
|
+
Tool(
|
|
177
|
+
name="get_stats",
|
|
178
|
+
description=(
|
|
179
|
+
"Get statistics about your context store — total vectors, "
|
|
180
|
+
"collection sizes, usage vs plan limits."
|
|
181
|
+
),
|
|
182
|
+
inputSchema={
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {},
|
|
185
|
+
},
|
|
186
|
+
),
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.call_tool()
|
|
191
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
192
|
+
if not _get_api_key():
|
|
193
|
+
return [TextContent(
|
|
194
|
+
type="text",
|
|
195
|
+
text="No API key configured. Set TENANT_API_KEY in your MCP config. "
|
|
196
|
+
"Get a free key at https://ctxstore.ai",
|
|
197
|
+
)]
|
|
198
|
+
|
|
199
|
+
if name == "search_context":
|
|
200
|
+
result = await _api("POST", "/api/v1/search", {
|
|
201
|
+
"query": arguments["query"],
|
|
202
|
+
"top_k": arguments.get("top_k", 20),
|
|
203
|
+
"source": arguments.get("source"),
|
|
204
|
+
"days_back": arguments.get("days_back"),
|
|
205
|
+
})
|
|
206
|
+
if "error" in result:
|
|
207
|
+
return [TextContent(type="text", text=result["error"])]
|
|
208
|
+
return [TextContent(type="text", text=_format_search_results(result))]
|
|
209
|
+
|
|
210
|
+
elif name == "search_facts":
|
|
211
|
+
result = await _api("POST", "/api/v1/facts/search", {
|
|
212
|
+
"query": arguments["query"],
|
|
213
|
+
"category": arguments.get("category"),
|
|
214
|
+
"top_k": arguments.get("top_k", 10),
|
|
215
|
+
})
|
|
216
|
+
if "error" in result:
|
|
217
|
+
return [TextContent(type="text", text=result["error"])]
|
|
218
|
+
return [TextContent(type="text", text=_format_fact_results(result))]
|
|
219
|
+
|
|
220
|
+
elif name == "store_fact":
|
|
221
|
+
result = await _api("POST", "/api/v1/facts", {
|
|
222
|
+
"text": arguments["text"],
|
|
223
|
+
"category": arguments["category"],
|
|
224
|
+
"is_permanent": arguments.get("is_permanent", True),
|
|
225
|
+
})
|
|
226
|
+
if "error" in result:
|
|
227
|
+
return [TextContent(type="text", text=result["error"])]
|
|
228
|
+
return [TextContent(
|
|
229
|
+
type="text",
|
|
230
|
+
text=f"Fact stored (id={result.get('fact_id', '?')}, "
|
|
231
|
+
f"category={arguments['category']}, "
|
|
232
|
+
f"permanent={arguments.get('is_permanent', True)}): "
|
|
233
|
+
f"{arguments['text']}",
|
|
234
|
+
)]
|
|
235
|
+
|
|
236
|
+
elif name == "delete_fact":
|
|
237
|
+
result = await _api("DELETE", f"/api/v1/facts/{arguments['fact_id']}")
|
|
238
|
+
if "error" in result:
|
|
239
|
+
return [TextContent(type="text", text=result["error"])]
|
|
240
|
+
return [TextContent(type="text", text=f"Fact {arguments['fact_id']} deleted.")]
|
|
241
|
+
|
|
242
|
+
elif name == "get_stats":
|
|
243
|
+
result = await _api("GET", "/api/v1/stats")
|
|
244
|
+
if "error" in result:
|
|
245
|
+
return [TextContent(type="text", text=result["error"])]
|
|
246
|
+
return [TextContent(type="text", text=_format_stats(result))]
|
|
247
|
+
|
|
248
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _format_search_results(data: dict) -> str:
|
|
252
|
+
results = data.get("results", [])
|
|
253
|
+
if not results:
|
|
254
|
+
return "No relevant context found."
|
|
255
|
+
formatted = []
|
|
256
|
+
for r in results:
|
|
257
|
+
header = f"[{r.get('source', '?')}|{r.get('age', '?')}|score:{r.get('score', 0):.3f}]"
|
|
258
|
+
title = r.get("conversation_title", "")
|
|
259
|
+
if title:
|
|
260
|
+
header += f" ({title})"
|
|
261
|
+
formatted.append(f"{header}\n{r.get('text', '')}\n")
|
|
262
|
+
return "\n---\n".join(formatted)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _format_fact_results(data: dict) -> str:
|
|
266
|
+
results = data.get("results", [])
|
|
267
|
+
if not results:
|
|
268
|
+
return "No matching facts found."
|
|
269
|
+
formatted = []
|
|
270
|
+
for r in results:
|
|
271
|
+
perm = "permanent" if r.get("is_permanent") else "decaying"
|
|
272
|
+
formatted.append(
|
|
273
|
+
f"[{r.get('category', '?')}|{perm}|score:{r.get('score', 0):.3f}] "
|
|
274
|
+
f"{r.get('text', '')}"
|
|
275
|
+
)
|
|
276
|
+
return "\n".join(formatted)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _format_stats(data: dict) -> str:
|
|
280
|
+
import json
|
|
281
|
+
return json.dumps(data, indent=2)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def main():
|
|
285
|
+
logging.basicConfig(
|
|
286
|
+
level=logging.INFO,
|
|
287
|
+
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
288
|
+
stream=sys.stderr,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Resolve API key on startup (triggers auto-provision if needed)
|
|
292
|
+
api_key = _get_api_key()
|
|
293
|
+
if not api_key:
|
|
294
|
+
logger.warning("No API key resolved — tools will prompt for signup")
|
|
295
|
+
|
|
296
|
+
logger.info(f"ctxstore MCP server starting (endpoint: {BASE_URL})")
|
|
297
|
+
|
|
298
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
299
|
+
await app.run(
|
|
300
|
+
read_stream,
|
|
301
|
+
write_stream,
|
|
302
|
+
app.create_initialization_options(),
|
|
303
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctxstore-setup — one-command MCP client configuration.
|
|
3
|
+
|
|
4
|
+
Detects installed AI clients, injects ctxstore MCP config, provisions tenant.
|
|
5
|
+
Usage: ctxstore-setup
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .auth import get_or_provision
|
|
16
|
+
|
|
17
|
+
CTXSTORE_URL = os.getenv("CTXSTORE_URL", "https://ctxstore.ai").rstrip("/")
|
|
18
|
+
|
|
19
|
+
# ── MCP client config locations ────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
MCP_CLIENTS = [
|
|
22
|
+
{
|
|
23
|
+
"name": "Claude Desktop",
|
|
24
|
+
"paths": [
|
|
25
|
+
Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
|
|
26
|
+
Path.home() / ".config" / "claude" / "claude_desktop_config.json",
|
|
27
|
+
],
|
|
28
|
+
"wrapper": "mcpServers",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "Claude Code",
|
|
32
|
+
"paths": [Path.home() / ".claude.json"],
|
|
33
|
+
"wrapper": "mcpServers",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "Cursor",
|
|
37
|
+
"paths": [Path.home() / ".cursor" / "mcp.json"],
|
|
38
|
+
"wrapper": "mcpServers",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "VS Code",
|
|
42
|
+
"paths": [Path.home() / ".vscode" / "mcp.json"],
|
|
43
|
+
"wrapper": "servers",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "Windsurf",
|
|
47
|
+
"paths": [Path.home() / ".windsurf" / "mcp.json"],
|
|
48
|
+
"wrapper": "mcpServers",
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _inject_config(config_path: Path, wrapper: str, api_key: str) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Non-destructively inject ctxstore entry into an MCP client config.
|
|
56
|
+
Backs up the original file before modifying.
|
|
57
|
+
Returns True on success.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
with open(config_path) as f:
|
|
61
|
+
try:
|
|
62
|
+
config = json.load(f)
|
|
63
|
+
except json.JSONDecodeError:
|
|
64
|
+
config = {}
|
|
65
|
+
|
|
66
|
+
if wrapper not in config:
|
|
67
|
+
config[wrapper] = {}
|
|
68
|
+
|
|
69
|
+
config[wrapper]["ctxstore"] = {
|
|
70
|
+
"command": "ctxstore-mcp",
|
|
71
|
+
"env": {"TENANT_API_KEY": api_key},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Backup
|
|
75
|
+
shutil.copy2(config_path, str(config_path) + ".bak")
|
|
76
|
+
|
|
77
|
+
with open(config_path, "w") as f:
|
|
78
|
+
json.dump(config, f, indent=2)
|
|
79
|
+
f.write("\n")
|
|
80
|
+
|
|
81
|
+
return True
|
|
82
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
83
|
+
print(f" ✗ Error modifying {config_path}: {e}", file=sys.stderr)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _health_check() -> bool:
|
|
88
|
+
"""Check if ctxstore.ai is reachable."""
|
|
89
|
+
try:
|
|
90
|
+
import httpx
|
|
91
|
+
resp = httpx.get(f"{CTXSTORE_URL}/api/health", timeout=10.0)
|
|
92
|
+
return resp.status_code == 200 and "ok" in resp.text.lower()
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
print("\n✦ ctxstore-setup\n")
|
|
99
|
+
|
|
100
|
+
# Step 1: Get/provision API key
|
|
101
|
+
print("→ Checking credentials...")
|
|
102
|
+
try:
|
|
103
|
+
api_key = get_or_provision()
|
|
104
|
+
print(f" ✓ API key ready")
|
|
105
|
+
except RuntimeError as e:
|
|
106
|
+
print(f" ✗ {e}", file=sys.stderr)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
# Step 2: Detect and configure MCP clients
|
|
110
|
+
print("\n→ Detecting MCP clients...")
|
|
111
|
+
configured = []
|
|
112
|
+
found_any = False
|
|
113
|
+
|
|
114
|
+
for client in MCP_CLIENTS:
|
|
115
|
+
for config_path in client["paths"]:
|
|
116
|
+
if config_path.exists():
|
|
117
|
+
found_any = True
|
|
118
|
+
print(f" Found: {client['name']} ({config_path})")
|
|
119
|
+
success = _inject_config(config_path, client["wrapper"], api_key)
|
|
120
|
+
if success:
|
|
121
|
+
configured.append(client["name"])
|
|
122
|
+
print(f" ✓ Configured {client['name']}")
|
|
123
|
+
break # Only configure first found path per client
|
|
124
|
+
|
|
125
|
+
if not found_any:
|
|
126
|
+
print(" No MCP clients detected.")
|
|
127
|
+
print("\n Add this to your client's MCP config manually:")
|
|
128
|
+
print("""
|
|
129
|
+
{
|
|
130
|
+
"mcpServers": {
|
|
131
|
+
"ctxstore": {
|
|
132
|
+
"command": "ctxstore-mcp",
|
|
133
|
+
"env": { "TENANT_API_KEY": \"""" + api_key + """\" }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}""")
|
|
137
|
+
|
|
138
|
+
# Step 3: Health check
|
|
139
|
+
print("\n→ Health check...")
|
|
140
|
+
if _health_check():
|
|
141
|
+
print(" ✓ ctxstore.ai is reachable")
|
|
142
|
+
else:
|
|
143
|
+
print(" ⚠ Could not reach ctxstore.ai — check your connection")
|
|
144
|
+
|
|
145
|
+
# Step 4: Summary
|
|
146
|
+
print("\n" + "━" * 50)
|
|
147
|
+
print("✓ ctxstore-setup complete!\n")
|
|
148
|
+
if configured:
|
|
149
|
+
print(f" Configured: {', '.join(configured)}")
|
|
150
|
+
print(" → Restart your AI client to activate memory")
|
|
151
|
+
print(f"\n Docs: {CTXSTORE_URL}/docs")
|
|
152
|
+
print("━" * 50 + "\n")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctxstore-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Persistent AI memory via MCP. Give your AI agent long-term memory in 30 seconds.
|
|
5
|
+
Author-email: "ctxstore.ai" <hello@ctxstore.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ctxstore.ai
|
|
8
|
+
Project-URL: Documentation, https://ctxstore.ai/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/ctxstore-ai/ctxstore-mcp
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/ctxstore-ai/ctxstore-mcp/issues
|
|
11
|
+
Keywords: mcp,ai,memory,context,vector,llm,claude,cursor
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mcp>=1.0.0
|
|
24
|
+
Requires-Dist: httpx>=0.25.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# ctxstore-mcp
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
ctxstore_mcp/__init__.py
|
|
5
|
+
ctxstore_mcp/__main__.py
|
|
6
|
+
ctxstore_mcp/auth.py
|
|
7
|
+
ctxstore_mcp/server.py
|
|
8
|
+
ctxstore_mcp/setup_cli.py
|
|
9
|
+
ctxstore_mcp.egg-info/PKG-INFO
|
|
10
|
+
ctxstore_mcp.egg-info/SOURCES.txt
|
|
11
|
+
ctxstore_mcp.egg-info/dependency_links.txt
|
|
12
|
+
ctxstore_mcp.egg-info/entry_points.txt
|
|
13
|
+
ctxstore_mcp.egg-info/requires.txt
|
|
14
|
+
ctxstore_mcp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ctxstore_mcp
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ctxstore-mcp"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Persistent AI memory via MCP. Give your AI agent long-term memory in 30 seconds."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "ctxstore.ai", email = "hello@ctxstore.ai"},
|
|
10
|
+
]
|
|
11
|
+
keywords = ["mcp", "ai", "memory", "context", "vector", "llm", "claude", "cursor"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 5 - Production/Stable",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"mcp>=1.0.0",
|
|
24
|
+
"httpx>=0.25.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
ctxstore-mcp = "ctxstore_mcp:main"
|
|
29
|
+
ctxstore-setup = "ctxstore_mcp.setup_cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://ctxstore.ai"
|
|
33
|
+
Documentation = "https://ctxstore.ai/docs"
|
|
34
|
+
Repository = "https://github.com/ctxstore-ai/ctxstore-mcp"
|
|
35
|
+
"Bug Tracker" = "https://github.com/ctxstore-ai/ctxstore-mcp/issues"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
39
|
+
build-backend = "setuptools.build_meta"
|