keepm 0.2.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.
- keepm/__init__.py +1 -0
- keepm/cli.py +18 -0
- keepm/config.py +97 -0
- keepm/database.py +572 -0
- keepm/doctor.py +131 -0
- keepm/instructions.py +226 -0
- keepm/integrations.py +253 -0
- keepm/markdown.py +152 -0
- keepm/models.py +48 -0
- keepm/projects.py +57 -0
- keepm/server.py +450 -0
- keepm/service.py +299 -0
- keepm/setup.py +405 -0
- keepm/storage.py +66 -0
- keepm/sync.py +230 -0
- keepm/watcher.py +113 -0
- keepm-0.2.0.dist-info/METADATA +15 -0
- keepm-0.2.0.dist-info/RECORD +22 -0
- keepm-0.2.0.dist-info/WHEEL +5 -0
- keepm-0.2.0.dist-info/entry_points.txt +2 -0
- keepm-0.2.0.dist-info/licenses/LICENSE +21 -0
- keepm-0.2.0.dist-info/top_level.txt +1 -0
keepm/server.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import hashlib
|
|
8
|
+
import sqlite3
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from keepm import __version__
|
|
15
|
+
from keepm.config import ConfigStore, KeepMConfig, MemoryLanguage, default_data_root
|
|
16
|
+
from keepm.database import SCHEMA_VERSION, MemoryDatabase
|
|
17
|
+
from keepm.models import MemoryDocument
|
|
18
|
+
from keepm.projects import resolve_workspace
|
|
19
|
+
from keepm.service import MemoryService
|
|
20
|
+
from keepm.storage import MemoryStorage
|
|
21
|
+
from keepm.sync import SyncEngine
|
|
22
|
+
from keepm.watcher import WatcherService
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from fastmcp import Context, FastMCP
|
|
26
|
+
except ImportError:
|
|
27
|
+
Context = Any # type: ignore[misc,assignment]
|
|
28
|
+
FastMCP = None # type: ignore[assignment]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _vault_id(config: KeepMConfig) -> str:
|
|
32
|
+
identity = f"{config.vault_path}\0{config.memory_root.resolve()}"
|
|
33
|
+
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _jsonable(value: Any) -> Any:
|
|
37
|
+
if dataclasses.is_dataclass(value):
|
|
38
|
+
return _jsonable(dataclasses.asdict(value))
|
|
39
|
+
if isinstance(value, Path):
|
|
40
|
+
return str(value)
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {str(key): _jsonable(item) for key, item in value.items()}
|
|
43
|
+
if isinstance(value, (tuple, list, set)):
|
|
44
|
+
return [_jsonable(item) for item in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _document_result(
|
|
49
|
+
document: MemoryDocument, checksum: str, *, max_chars: int
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
if max_chars < 1:
|
|
52
|
+
raise ValueError("max_chars must be positive")
|
|
53
|
+
payload = _jsonable(document)
|
|
54
|
+
body = str(payload["body"])
|
|
55
|
+
truncated = len(body) > max_chars
|
|
56
|
+
payload["body"] = body[:max_chars]
|
|
57
|
+
return {
|
|
58
|
+
"memory": payload,
|
|
59
|
+
"checksum": checksum,
|
|
60
|
+
"output_chars": len(payload["body"]),
|
|
61
|
+
"truncated": truncated,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Runtime:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
config_path: Path | None = None,
|
|
70
|
+
data_root: Path | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.config_store = ConfigStore(config_path)
|
|
73
|
+
self.data_root = (data_root or default_data_root()).expanduser().resolve()
|
|
74
|
+
self.config: KeepMConfig | None = None
|
|
75
|
+
self.database: MemoryDatabase | None = None
|
|
76
|
+
self.storage: MemoryStorage | None = None
|
|
77
|
+
self.sync_engine: SyncEngine | None = None
|
|
78
|
+
self.service: MemoryService | None = None
|
|
79
|
+
self.watcher: WatcherService | None = None
|
|
80
|
+
self.last_error: str | None = None
|
|
81
|
+
self.recovered_index: str | None = None
|
|
82
|
+
self._lock = asyncio.Lock()
|
|
83
|
+
|
|
84
|
+
async def start(self) -> None:
|
|
85
|
+
async with self._lock:
|
|
86
|
+
if self.service is not None:
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
config = self.config_store.load()
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return
|
|
92
|
+
except Exception as error:
|
|
93
|
+
self.last_error = f"cannot load KeepM configuration: {error}"
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
await self._build_locked(config)
|
|
97
|
+
except Exception as error:
|
|
98
|
+
self.last_error = f"cannot initialize KeepM: {error}"
|
|
99
|
+
|
|
100
|
+
async def configure(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
vault_path: str,
|
|
104
|
+
memory_dir: str = "Memory",
|
|
105
|
+
stale_after_seconds: int = 30,
|
|
106
|
+
language: MemoryLanguage | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
vault = Path(vault_path).expanduser().resolve()
|
|
109
|
+
if not vault.is_dir():
|
|
110
|
+
raise ValueError(f"vault_path must be an existing directory: {vault}")
|
|
111
|
+
existing = self.config
|
|
112
|
+
if existing is None:
|
|
113
|
+
existing = self.config_store.load_optional()
|
|
114
|
+
config = KeepMConfig(
|
|
115
|
+
vault_path=vault,
|
|
116
|
+
memory_dir=memory_dir,
|
|
117
|
+
stale_after_seconds=stale_after_seconds,
|
|
118
|
+
language=language or (existing.language if existing is not None else "en"),
|
|
119
|
+
)
|
|
120
|
+
async with self._lock:
|
|
121
|
+
await self._teardown_locked()
|
|
122
|
+
self.config_store.save(config)
|
|
123
|
+
await self._build_locked(config)
|
|
124
|
+
self.last_error = None
|
|
125
|
+
return self.status()
|
|
126
|
+
|
|
127
|
+
async def _build_locked(self, config: KeepMConfig) -> None:
|
|
128
|
+
self.config = config
|
|
129
|
+
self.storage = MemoryStorage(config)
|
|
130
|
+
self.database = self._open_database(config)
|
|
131
|
+
self.sync_engine = SyncEngine(self.storage, self.database)
|
|
132
|
+
self.service = MemoryService(
|
|
133
|
+
config, self.storage, self.database, self.sync_engine
|
|
134
|
+
)
|
|
135
|
+
report = self.sync_engine.quick_reconcile()
|
|
136
|
+
self.database.set_state("dirty", "1" if report.errors else "0")
|
|
137
|
+
self.watcher = WatcherService(config, self.sync_engine)
|
|
138
|
+
await self.watcher.start()
|
|
139
|
+
|
|
140
|
+
def _open_database(self, config: KeepMConfig) -> MemoryDatabase:
|
|
141
|
+
database_path = self.data_root / "indexes" / f"{_vault_id(config)}.sqlite3"
|
|
142
|
+
database: MemoryDatabase | None = None
|
|
143
|
+
try:
|
|
144
|
+
database = MemoryDatabase(database_path)
|
|
145
|
+
if not database.quick_check():
|
|
146
|
+
raise sqlite3.DatabaseError("PRAGMA quick_check failed")
|
|
147
|
+
if database.get_state("schema_version") != SCHEMA_VERSION:
|
|
148
|
+
raise sqlite3.DatabaseError("incompatible KeepM index schema")
|
|
149
|
+
canonical_root = database.get_state("canonical_root")
|
|
150
|
+
expected_root = str(config.memory_root.resolve())
|
|
151
|
+
if canonical_root not in (None, expected_root):
|
|
152
|
+
raise sqlite3.DatabaseError("index belongs to a different Memory root")
|
|
153
|
+
except (OSError, RuntimeError, sqlite3.DatabaseError) as error:
|
|
154
|
+
if database is not None:
|
|
155
|
+
database.close()
|
|
156
|
+
if database_path.exists():
|
|
157
|
+
suffix = dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
158
|
+
preserved = database_path.with_name(
|
|
159
|
+
f"{database_path.name}.corrupt-{suffix}"
|
|
160
|
+
)
|
|
161
|
+
database_path.replace(preserved)
|
|
162
|
+
for sidecar_suffix in ("-wal", "-shm"):
|
|
163
|
+
sidecar = Path(str(database_path) + sidecar_suffix)
|
|
164
|
+
if sidecar.exists():
|
|
165
|
+
sidecar.replace(Path(str(preserved) + sidecar_suffix))
|
|
166
|
+
self.recovered_index = f"{preserved}: {error}"
|
|
167
|
+
database = MemoryDatabase(database_path)
|
|
168
|
+
|
|
169
|
+
database.set_state("canonical_root", str(config.memory_root.resolve()))
|
|
170
|
+
return database
|
|
171
|
+
|
|
172
|
+
async def _teardown_locked(self) -> None:
|
|
173
|
+
if self.watcher is not None:
|
|
174
|
+
await self.watcher.stop()
|
|
175
|
+
if self.database is not None:
|
|
176
|
+
self.database.close()
|
|
177
|
+
self.config = None
|
|
178
|
+
self.database = None
|
|
179
|
+
self.storage = None
|
|
180
|
+
self.sync_engine = None
|
|
181
|
+
self.service = None
|
|
182
|
+
self.watcher = None
|
|
183
|
+
|
|
184
|
+
async def close(self) -> None:
|
|
185
|
+
async with self._lock:
|
|
186
|
+
await self._teardown_locked()
|
|
187
|
+
|
|
188
|
+
def require_service(self) -> MemoryService:
|
|
189
|
+
if self.service is None:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
"KeepM is not configured. Call memory_configure with your Obsidian Vault path."
|
|
192
|
+
)
|
|
193
|
+
return self.service
|
|
194
|
+
|
|
195
|
+
def status(self, workspace: Path | None = None) -> dict[str, Any]:
|
|
196
|
+
if self.service is None:
|
|
197
|
+
return {
|
|
198
|
+
"configured": False,
|
|
199
|
+
"config_path": str(self.config_store.path),
|
|
200
|
+
"error": self.last_error,
|
|
201
|
+
}
|
|
202
|
+
status = self.service.status(workspace=workspace)
|
|
203
|
+
status.update(
|
|
204
|
+
{
|
|
205
|
+
"watcher_running": bool(self.watcher and self.watcher.running),
|
|
206
|
+
"watcher_error": self.watcher.last_error if self.watcher else None,
|
|
207
|
+
"recovered_index": self.recovered_index,
|
|
208
|
+
"error": self.last_error,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
return status
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def _workspace_from_context(
|
|
215
|
+
context: Any, workspace_path: str | None
|
|
216
|
+
) -> Path:
|
|
217
|
+
client_roots: list[str] = []
|
|
218
|
+
list_roots = getattr(context, "list_roots", None)
|
|
219
|
+
if list_roots is not None:
|
|
220
|
+
try:
|
|
221
|
+
roots = await list_roots()
|
|
222
|
+
for root in roots or ():
|
|
223
|
+
client_roots.append(str(getattr(root, "uri", root)))
|
|
224
|
+
except Exception:
|
|
225
|
+
client_roots = []
|
|
226
|
+
return resolve_workspace(client_roots, workspace_path, Path.cwd())
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def create_mcp(
|
|
230
|
+
*,
|
|
231
|
+
config_path: Path | None = None,
|
|
232
|
+
data_root: Path | None = None,
|
|
233
|
+
) -> Any:
|
|
234
|
+
if FastMCP is None:
|
|
235
|
+
raise RuntimeError(
|
|
236
|
+
"FastMCP is not installed. Install KeepM with its declared dependencies."
|
|
237
|
+
)
|
|
238
|
+
runtime = Runtime(config_path=config_path, data_root=data_root)
|
|
239
|
+
|
|
240
|
+
@asynccontextmanager
|
|
241
|
+
async def lifespan(server: Any):
|
|
242
|
+
await runtime.start()
|
|
243
|
+
try:
|
|
244
|
+
yield {}
|
|
245
|
+
finally:
|
|
246
|
+
await runtime.close()
|
|
247
|
+
|
|
248
|
+
server = FastMCP("KeepM", lifespan=lifespan)
|
|
249
|
+
read_only = {"readOnlyHint": True, "openWorldHint": False}
|
|
250
|
+
local_write = {
|
|
251
|
+
"readOnlyHint": False,
|
|
252
|
+
"destructiveHint": False,
|
|
253
|
+
"openWorldHint": False,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@server.tool(annotations=local_write)
|
|
257
|
+
async def memory_configure(
|
|
258
|
+
vault_path: str,
|
|
259
|
+
memory_dir: str = "Memory",
|
|
260
|
+
stale_after_seconds: int = 30,
|
|
261
|
+
language: MemoryLanguage | None = None,
|
|
262
|
+
) -> dict[str, Any]:
|
|
263
|
+
"""Configure the one local Obsidian Vault used by KeepM."""
|
|
264
|
+
return await runtime.configure(
|
|
265
|
+
vault_path=vault_path,
|
|
266
|
+
memory_dir=memory_dir,
|
|
267
|
+
stale_after_seconds=stale_after_seconds,
|
|
268
|
+
language=language,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
@server.tool(annotations=read_only)
|
|
272
|
+
async def memory_status(
|
|
273
|
+
ctx: Context, workspace_path: str | None = None
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
"""Report configuration, project, watcher, database, and sync state."""
|
|
276
|
+
workspace = None
|
|
277
|
+
if runtime.service is not None:
|
|
278
|
+
workspace = await _workspace_from_context(ctx, workspace_path)
|
|
279
|
+
return runtime.status(workspace)
|
|
280
|
+
|
|
281
|
+
@server.tool(annotations=read_only)
|
|
282
|
+
async def memory_context(
|
|
283
|
+
ctx: Context,
|
|
284
|
+
query: str,
|
|
285
|
+
workspace_path: str | None = None,
|
|
286
|
+
limit: int = 10,
|
|
287
|
+
max_chars: int = 6_000,
|
|
288
|
+
) -> dict[str, Any]:
|
|
289
|
+
"""Return bounded pinned and query-relevant project/global memories."""
|
|
290
|
+
service = runtime.require_service()
|
|
291
|
+
workspace = await _workspace_from_context(ctx, workspace_path)
|
|
292
|
+
await service.ensure_fresh()
|
|
293
|
+
items = service.context(
|
|
294
|
+
workspace=workspace, query=query, limit=limit, max_chars=max_chars
|
|
295
|
+
)
|
|
296
|
+
output_chars = sum(len(item.snippet) for item in items)
|
|
297
|
+
return {
|
|
298
|
+
"items": _jsonable(items),
|
|
299
|
+
"count": len(items),
|
|
300
|
+
"output_chars": output_chars,
|
|
301
|
+
"truncated": len(items) >= limit or output_chars >= max_chars,
|
|
302
|
+
"language": service.config.language,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@server.tool(annotations=read_only)
|
|
306
|
+
async def memory_search(
|
|
307
|
+
ctx: Context,
|
|
308
|
+
query: str,
|
|
309
|
+
workspace_path: str | None = None,
|
|
310
|
+
scope: str | None = None,
|
|
311
|
+
limit: int = 10,
|
|
312
|
+
) -> dict[str, Any]:
|
|
313
|
+
"""Search the active project and global Markdown memory index."""
|
|
314
|
+
service = runtime.require_service()
|
|
315
|
+
workspace = await _workspace_from_context(ctx, workspace_path)
|
|
316
|
+
await service.ensure_fresh()
|
|
317
|
+
items = service.search(
|
|
318
|
+
workspace=workspace, query=query, scope=scope, limit=limit
|
|
319
|
+
)
|
|
320
|
+
return {"items": _jsonable(items), "count": len(items), "limit": limit}
|
|
321
|
+
|
|
322
|
+
@server.tool(annotations=read_only)
|
|
323
|
+
async def memory_read(
|
|
324
|
+
ctx: Context,
|
|
325
|
+
identifier: str,
|
|
326
|
+
workspace_path: str | None = None,
|
|
327
|
+
max_chars: int = 20_000,
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
"""Read one authoritative Markdown memory by ID, name, or safe path."""
|
|
330
|
+
service = runtime.require_service()
|
|
331
|
+
workspace = await _workspace_from_context(ctx, workspace_path)
|
|
332
|
+
await service.ensure_fresh()
|
|
333
|
+
document, checksum = service.read_with_checksum(
|
|
334
|
+
workspace=workspace, identifier=identifier
|
|
335
|
+
)
|
|
336
|
+
return _document_result(document, checksum, max_chars=max_chars)
|
|
337
|
+
|
|
338
|
+
@server.tool(annotations=local_write)
|
|
339
|
+
async def memory_write(
|
|
340
|
+
ctx: Context,
|
|
341
|
+
name: str,
|
|
342
|
+
description: str,
|
|
343
|
+
memory_type: str,
|
|
344
|
+
body: str,
|
|
345
|
+
scope: str = "project",
|
|
346
|
+
tags: list[str] | None = None,
|
|
347
|
+
pinned: bool = False,
|
|
348
|
+
workspace_path: str | None = None,
|
|
349
|
+
expected_checksum: str | None = None,
|
|
350
|
+
) -> dict[str, Any]:
|
|
351
|
+
"""Create or update an atomic Markdown memory and its derived index."""
|
|
352
|
+
service = runtime.require_service()
|
|
353
|
+
workspace = await _workspace_from_context(ctx, workspace_path)
|
|
354
|
+
await service.ensure_fresh()
|
|
355
|
+
document = service.write(
|
|
356
|
+
workspace=workspace,
|
|
357
|
+
scope=scope,
|
|
358
|
+
name=name,
|
|
359
|
+
description=description,
|
|
360
|
+
memory_type=memory_type,
|
|
361
|
+
body=body,
|
|
362
|
+
tags=tuple(tags or ()),
|
|
363
|
+
pinned=pinned,
|
|
364
|
+
expected_checksum=expected_checksum,
|
|
365
|
+
)
|
|
366
|
+
record = service.database.get_by_id(document.id)
|
|
367
|
+
if record is None:
|
|
368
|
+
raise RuntimeError("memory was written but is absent from the index")
|
|
369
|
+
return _document_result(document, record.checksum, max_chars=2_000)
|
|
370
|
+
|
|
371
|
+
@server.tool(
|
|
372
|
+
annotations={
|
|
373
|
+
"readOnlyHint": False,
|
|
374
|
+
"destructiveHint": True,
|
|
375
|
+
"openWorldHint": False,
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
async def memory_delete(
|
|
379
|
+
ctx: Context,
|
|
380
|
+
identifier: str,
|
|
381
|
+
workspace_path: str | None = None,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
"""Soft-delete one memory into Memory/.trash."""
|
|
384
|
+
service = runtime.require_service()
|
|
385
|
+
workspace = await _workspace_from_context(ctx, workspace_path)
|
|
386
|
+
await service.ensure_fresh()
|
|
387
|
+
trashed = service.delete(workspace=workspace, identifier=identifier)
|
|
388
|
+
return {"deleted": True, "trash_path": str(trashed)}
|
|
389
|
+
|
|
390
|
+
@server.tool(annotations=read_only)
|
|
391
|
+
async def memory_links(
|
|
392
|
+
ctx: Context,
|
|
393
|
+
identifier: str,
|
|
394
|
+
direction: str = "both",
|
|
395
|
+
workspace_path: str | None = None,
|
|
396
|
+
) -> dict[str, Any]:
|
|
397
|
+
"""Return forward WikiLinks and/or backlinks for one memory."""
|
|
398
|
+
service = runtime.require_service()
|
|
399
|
+
workspace = await _workspace_from_context(ctx, workspace_path)
|
|
400
|
+
await service.ensure_fresh()
|
|
401
|
+
result = _jsonable(
|
|
402
|
+
service.links(
|
|
403
|
+
workspace=workspace, identifier=identifier, direction=direction
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
result["forward_count"] = len(result.get("forward", []))
|
|
407
|
+
result["backlink_count"] = len(result.get("backlinks", []))
|
|
408
|
+
result["count"] = result["forward_count"] + result["backlink_count"]
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
@server.tool(annotations=local_write)
|
|
412
|
+
async def memory_sync(full: bool = False) -> dict[str, Any]:
|
|
413
|
+
"""Run quick checksum reconciliation or a full derived-index rebuild."""
|
|
414
|
+
report = runtime.require_service().sync(full=full)
|
|
415
|
+
payload = _jsonable(report)
|
|
416
|
+
payload["error_count"] = len(payload["errors"])
|
|
417
|
+
return {"report": payload}
|
|
418
|
+
|
|
419
|
+
@server.tool(annotations=read_only)
|
|
420
|
+
async def memory_doctor() -> dict[str, Any]:
|
|
421
|
+
"""Diagnose Markdown and SQLite consistency without modifying memories."""
|
|
422
|
+
findings = runtime.require_service().doctor()
|
|
423
|
+
return {"findings": _jsonable(findings), "count": len(findings)}
|
|
424
|
+
|
|
425
|
+
setattr(server, "_keepm_runtime", runtime)
|
|
426
|
+
return server
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
mcp = create_mcp()
|
|
431
|
+
except RuntimeError:
|
|
432
|
+
mcp = None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
436
|
+
parser = argparse.ArgumentParser(
|
|
437
|
+
prog="keepm",
|
|
438
|
+
description="Run the KeepM local Markdown memory MCP over stdio.",
|
|
439
|
+
)
|
|
440
|
+
parser.add_argument("--version", action="version", version=f"keepm {__version__}")
|
|
441
|
+
parser.parse_args(argv)
|
|
442
|
+
if mcp is None:
|
|
443
|
+
parser.error(
|
|
444
|
+
"FastMCP is not installed; install KeepM with its declared dependencies"
|
|
445
|
+
)
|
|
446
|
+
mcp.run(transport="stdio")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
main()
|