tracenor-mcp 1.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.
@@ -0,0 +1,153 @@
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ lerna-debug.log*
8
+
9
+ # Diagnostic reports (https://nodejs.org/api/report.html)
10
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11
+
12
+ # Runtime data
13
+ pids
14
+ *.pid
15
+ *.seed
16
+ *.pid.lock
17
+
18
+ # Directory for instrumented libs generated by jscoverage/JSCover
19
+ lib-cov
20
+
21
+ # Coverage directory used by tools like istanbul
22
+ coverage
23
+ *.lcov
24
+
25
+ # nyc test coverage
26
+ .nyc_output
27
+
28
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29
+ .grunt
30
+
31
+ # Bower dependency directory (https://bower.io/)
32
+ bower_components
33
+
34
+ # node-waf configuration
35
+ .lock-wscript
36
+
37
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
38
+ build/Release
39
+
40
+ # Dependency directories
41
+ node_modules/
42
+ jspm_packages/
43
+ .venv/
44
+ .artifacts/
45
+ __pycache__/
46
+
47
+ # Snowpack dependency directory (https://snowpack.dev/)
48
+ web_modules/
49
+
50
+ # TypeScript cache
51
+ *.tsbuildinfo
52
+
53
+ # Optional npm cache directory
54
+ .npm
55
+
56
+ # Optional eslint cache
57
+ .eslintcache
58
+
59
+ # Optional stylelint cache
60
+ .stylelintcache
61
+
62
+ # Optional REPL history
63
+ .node_repl_history
64
+
65
+ # Output of 'npm pack'
66
+ *.tgz
67
+
68
+ # Yarn Integrity file
69
+ .yarn-integrity
70
+
71
+ # dotenv environment variable files
72
+ .env
73
+ .env.*
74
+ !.env.example
75
+
76
+ # parcel-bundler cache (https://parceljs.org/)
77
+ .cache
78
+ .parcel-cache
79
+
80
+ # Next.js build output
81
+ .next
82
+ out
83
+
84
+ # Nuxt.js build / generate output
85
+ .nuxt
86
+ dist
87
+ .output
88
+
89
+ # Gatsby files
90
+ .cache/
91
+ # Comment in the public line in if your project uses Gatsby and not Next.js
92
+ # https://nextjs.org/blog/next-9-1#public-directory-support
93
+ # public
94
+
95
+ # vuepress build output
96
+ .vuepress/dist
97
+
98
+ # vuepress v2.x temp directory
99
+ .temp
100
+
101
+ # Sveltekit cache directory
102
+ .svelte-kit/
103
+
104
+ # vitepress build output
105
+ **/.vitepress/dist
106
+
107
+ # vitepress cache directory
108
+ **/.vitepress/cache
109
+
110
+ # Docusaurus cache and generated files
111
+ .docusaurus
112
+
113
+ # Serverless directories
114
+ .serverless/
115
+
116
+ # FuseBox cache
117
+ .fusebox/
118
+
119
+ # DynamoDB Local files
120
+ .dynamodb/
121
+
122
+ # Firebase cache directory
123
+ .firebase/
124
+
125
+ # TernJS port file
126
+ .tern-port
127
+
128
+ # Stores VSCode versions used for testing VSCode extensions
129
+ .vscode-test
130
+
131
+ # pnpm
132
+ .pnpm-store
133
+
134
+ # Turborepo cache
135
+ .turbo/
136
+
137
+ # Reproducible benchmark output is uploaded by CI; selected release evidence is
138
+ # copied to docs/benchmarks intentionally.
139
+ artifacts/benchmarks/
140
+
141
+ # yarn v3
142
+ .pnp.*
143
+ .yarn/*
144
+ !.yarn/patches
145
+ !.yarn/plugins
146
+ !.yarn/releases
147
+ !.yarn/sdks
148
+ !.yarn/versions
149
+
150
+ # Vite files
151
+ vite.config.js.timestamp-*
152
+ vite.config.ts.timestamp-*
153
+ .vite/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracenor-mcp
3
+ Version: 1.0
4
+ Summary: Protocol-clean STDIO MCP connector for Tracenor.
5
+ Project-URL: Homepage, https://github.com/i9clouds/sharedmemoryai/tree/main/packages/mcp-server
6
+ Project-URL: Repository, https://github.com/i9clouds/sharedmemoryai.git
7
+ Project-URL: Issues, https://github.com/i9clouds/sharedmemoryai/issues
8
+ Author: i9clouds
9
+ License: UNLICENSED
10
+ Keywords: mcp,model-context-protocol,stdio,tracenor
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: httpx<1,>=0.28
16
+ Requires-Dist: mcp<2,>=1.26
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Tracenor MCP STDIO connector
20
+
21
+ Install the public package with uv:
22
+
23
+ ```sh
24
+ uv tool install tracenor-mcp
25
+ ```
26
+
27
+ Then register `tracenor-mcp` as an STDIO MCP server in your host. It requires
28
+ `SHAREDMEMORY_API_URL` and `SHAREDMEMORY_API_KEY`; see the full
29
+ [configuration guide](../../docs/mcp-stdio.md).
30
+
31
+ `tracenor-mcp` exposes four tools to an MCP-capable local host:
32
+
33
+ - `get_active_context`
34
+ - `search_memories`
35
+ - `propose_memory`
36
+ - `create_approved_memory`
37
+
38
+ The process owns the Git collector. Agents never select a workspace, vault,
39
+ repository, repository root or trusted Git metadata. The API determines which
40
+ vaults are eligible from the authenticated API key, platform permissions and
41
+ the trusted local Git detection.
42
+
43
+ See [`docs/mcp-stdio.md`](../../docs/mcp-stdio.md) for host setup and security
44
+ requirements.
@@ -0,0 +1,26 @@
1
+ # Tracenor MCP STDIO connector
2
+
3
+ Install the public package with uv:
4
+
5
+ ```sh
6
+ uv tool install tracenor-mcp
7
+ ```
8
+
9
+ Then register `tracenor-mcp` as an STDIO MCP server in your host. It requires
10
+ `SHAREDMEMORY_API_URL` and `SHAREDMEMORY_API_KEY`; see the full
11
+ [configuration guide](../../docs/mcp-stdio.md).
12
+
13
+ `tracenor-mcp` exposes four tools to an MCP-capable local host:
14
+
15
+ - `get_active_context`
16
+ - `search_memories`
17
+ - `propose_memory`
18
+ - `create_approved_memory`
19
+
20
+ The process owns the Git collector. Agents never select a workspace, vault,
21
+ repository, repository root or trusted Git metadata. The API determines which
22
+ vaults are eligible from the authenticated API key, platform permissions and
23
+ the trusted local Git detection.
24
+
25
+ See [`docs/mcp-stdio.md`](../../docs/mcp-stdio.md) for host setup and security
26
+ requirements.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "tracenor-mcp"
3
+ version = "1.0"
4
+ description = "Protocol-clean STDIO MCP connector for Tracenor."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "UNLICENSED" }
8
+ authors = [{ name = "i9clouds" }]
9
+ keywords = ["mcp", "model-context-protocol", "stdio", "tracenor"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3 :: Only",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ ]
15
+ dependencies = [
16
+ "httpx>=0.28,<1",
17
+ "mcp>=1.26,<2",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/i9clouds/sharedmemoryai/tree/main/packages/mcp-server"
22
+ Repository = "https://github.com/i9clouds/sharedmemoryai.git"
23
+ Issues = "https://github.com/i9clouds/sharedmemoryai/issues"
24
+
25
+ [project.scripts]
26
+ tracenor-mcp = "tracenor_mcp.server:main"
27
+
28
+ [dependency-groups]
29
+ dev = ["pytest>=8.3,<9"]
30
+
31
+ [build-system]
32
+ requires = ["hatchling>=1.27,<2"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ addopts = "-q"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/tracenor_mcp"]
@@ -0,0 +1,3 @@
1
+ """Tracenor's local-only STDIO MCP connector."""
2
+
3
+ __version__ = "1.0"
@@ -0,0 +1,411 @@
1
+ """Protocol-clean STDIO MCP server for Tracenor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+ from uuid import UUID, uuid4
15
+
16
+ import httpx
17
+ from mcp.server.fastmcp import FastMCP
18
+ from mcp.types import CallToolResult, TextContent
19
+
20
+ from . import __version__
21
+
22
+ MEMORY_TYPES = {
23
+ "decision",
24
+ "constraint",
25
+ "pattern",
26
+ "preference",
27
+ "context",
28
+ "runbook",
29
+ "incident",
30
+ "lesson_learned",
31
+ }
32
+ MEMORY_STATUSES = {"proposed", "active", "rejected", "superseded", "archived"}
33
+ ERROR_CODES = {
34
+ "validation_error",
35
+ "invalid_api_key",
36
+ "invalid_session",
37
+ "provisioning_pending",
38
+ "context_unavailable",
39
+ "not_found",
40
+ "quota_exceeded",
41
+ "conflict",
42
+ "target_ambiguous",
43
+ "write_target_unresolved",
44
+ "invalid_transition",
45
+ }
46
+ API_KEY_PATTERN = re.compile(r"^sm_live_[a-z0-9]{12}_[A-Za-z0-9_-]{43}$")
47
+ SPACE_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
48
+ GITHUB_SEGMENT_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_.-]*$", re.IGNORECASE)
49
+ CANONICALIZATION_VERSION = 1
50
+
51
+
52
+ class ConfigError(ValueError):
53
+ """Raised without including credential material in the message."""
54
+
55
+ def __init__(self) -> None:
56
+ super().__init__("invalid_mcp_config")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Config:
61
+ api_url: str
62
+ api_key: str
63
+ timeout_seconds: float
64
+
65
+
66
+ def load_config(environment: dict[str, str] | None = None) -> Config:
67
+ environment = environment or dict(os.environ)
68
+ raw_url = environment.get("SHAREDMEMORY_API_URL")
69
+ api_key = environment.get("SHAREDMEMORY_API_KEY")
70
+ if raw_url is None or api_key is None or not API_KEY_PATTERN.fullmatch(api_key):
71
+ raise ConfigError()
72
+ parsed = urlparse(raw_url)
73
+ if (
74
+ parsed.scheme != "https"
75
+ or not parsed.netloc
76
+ or parsed.username is not None
77
+ or parsed.password is not None
78
+ or parsed.query
79
+ or parsed.fragment
80
+ ):
81
+ raise ConfigError()
82
+ raw_timeout = environment.get("SHAREDMEMORY_TIMEOUT_MS", "10000")
83
+ if not raw_timeout.isdigit() or not 1 <= int(raw_timeout) <= 60_000:
84
+ raise ConfigError()
85
+ return Config(
86
+ api_url=raw_url if raw_url.endswith("/") else f"{raw_url}/",
87
+ api_key=api_key,
88
+ timeout_seconds=int(raw_timeout) / 1000,
89
+ )
90
+
91
+
92
+ async def run_git(cwd: str, *args: str) -> tuple[str, str]:
93
+ try:
94
+ process = await asyncio.create_subprocess_exec(
95
+ "git",
96
+ *args,
97
+ cwd=cwd,
98
+ stdin=asyncio.subprocess.DEVNULL,
99
+ stdout=asyncio.subprocess.PIPE,
100
+ stderr=asyncio.subprocess.DEVNULL,
101
+ )
102
+ stdout, _ = await asyncio.wait_for(process.communicate(), timeout=2)
103
+ except (OSError, TimeoutError):
104
+ return "failed", ""
105
+ if process.returncode != 0 or len(stdout) > 64 * 1024:
106
+ return "failed", ""
107
+ return "ok", stdout.decode("utf-8", errors="replace")
108
+
109
+
110
+ def one_line(value: str) -> str | None:
111
+ lines = [line.strip() for line in value.splitlines() if line.strip()]
112
+ return lines[0] if len(lines) == 1 else None
113
+
114
+
115
+ def canonicalize_remote(remote: str) -> tuple[str, str] | None:
116
+ candidate = remote.strip()
117
+ if not candidate or any(character.isspace() or ord(character) < 32 for character in candidate):
118
+ return None
119
+ match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", candidate)
120
+ if match and "://" not in candidate:
121
+ hostname, path = match.group(1), f"/{match.group(2)}"
122
+ else:
123
+ parsed = urlparse(candidate)
124
+ if parsed.scheme not in {"https", "http", "ssh"} or not parsed.hostname:
125
+ return None
126
+ try:
127
+ port = parsed.port
128
+ except ValueError:
129
+ return None
130
+ if port and not (
131
+ (parsed.scheme == "https" and port == 443)
132
+ or (parsed.scheme == "http" and port == 80)
133
+ or (parsed.scheme == "ssh" and port == 22)
134
+ ):
135
+ return None
136
+ hostname, path = parsed.hostname, parsed.path
137
+ if hostname.lower() != "github.com":
138
+ return None
139
+ segments = [segment for segment in path.rstrip("/").removesuffix(".git").split("/") if segment]
140
+ if len(segments) != 2 or any(
141
+ not GITHUB_SEGMENT_PATTERN.fullmatch(segment) or ".." in segment
142
+ for segment in segments
143
+ ):
144
+ return None
145
+ canonical = f"github.com/{segments[0].lower()}/{segments[1].lower()}"
146
+ return canonical, hashlib.sha256(canonical.encode()).hexdigest()
147
+
148
+
149
+ async def detect_local_git_context() -> dict[str, Any]:
150
+ root = os.environ.get("SHAREDMEMORY_REPO_PATH", "").strip() or os.getcwd()
151
+ status, output = await run_git(root, "rev-parse", "--show-toplevel")
152
+ worktree = one_line(output) if status == "ok" else None
153
+ if worktree is None:
154
+ return unavailable("not_detected")
155
+ status, output = await run_git(worktree, "remote")
156
+ remotes = [line.strip() for line in output.splitlines() if line.strip()] if status == "ok" else []
157
+ if not remotes:
158
+ return unavailable("not_detected")
159
+ status, output = await run_git(worktree, "symbolic-ref", "--quiet", "--short", "HEAD")
160
+ branch = one_line(output) if status == "ok" else None
161
+ remote: str | None = None
162
+ if branch:
163
+ status, output = await run_git(worktree, "config", "--get", f"branch.{branch}.remote")
164
+ upstream = one_line(output) if status == "ok" else None
165
+ if upstream in remotes:
166
+ remote = upstream
167
+ if remote is None:
168
+ remote = "origin" if "origin" in remotes else remotes[0] if len(remotes) == 1 else None
169
+ if remote is None:
170
+ return unavailable("ambiguous")
171
+ status, output = await run_git(worktree, "remote", "get-url", remote)
172
+ raw_remote = one_line(output) if status == "ok" else None
173
+ identity = canonicalize_remote(raw_remote) if raw_remote else None
174
+ if identity is None:
175
+ return unavailable("ambiguous")
176
+ canonical, fingerprint = identity
177
+ return {
178
+ "status": "detected",
179
+ "canonical": canonical,
180
+ "fingerprint": fingerprint,
181
+ "canonicalization_version": CANONICALIZATION_VERSION,
182
+ }
183
+
184
+
185
+ def unavailable(status: str) -> dict[str, None | str]:
186
+ return {
187
+ "status": status,
188
+ "canonical": None,
189
+ "fingerprint": None,
190
+ "canonicalization_version": None,
191
+ }
192
+
193
+
194
+ class ApiClient:
195
+ def __init__(self, config: Config) -> None:
196
+ self.config = config
197
+
198
+ async def request(self, path: str, body: dict[str, Any], is_write: bool, request_id: str) -> dict[str, Any]:
199
+ try:
200
+ async with httpx.AsyncClient(timeout=self.config.timeout_seconds) as client:
201
+ response = await client.post(
202
+ f"{self.config.api_url}{path}",
203
+ json=body,
204
+ headers={
205
+ "Authorization": f"Bearer {self.config.api_key}",
206
+ "Content-Type": "application/json",
207
+ "X-Request-Id": request_id,
208
+ },
209
+ )
210
+ except httpx.TimeoutException as error:
211
+ raise ApiError("request_timeout", "unknown" if is_write else "not_started", request_id) from error
212
+ except httpx.HTTPError as error:
213
+ raise ApiError("network_error", "unknown" if is_write else "not_started", request_id) from error
214
+ server_request_id = response.headers.get("x-request-id", request_id)
215
+ if response.is_error:
216
+ code = "api_error"
217
+ try:
218
+ candidate = response.json().get("error", {}).get("code")
219
+ if candidate in ERROR_CODES:
220
+ code = candidate
221
+ except (ValueError, AttributeError):
222
+ pass
223
+ raise ApiError(code, "not_started", server_request_id)
224
+ try:
225
+ payload = response.json()
226
+ except ValueError as error:
227
+ raise ApiError("invalid_api_response", "not_started", server_request_id) from error
228
+ if not isinstance(payload, dict):
229
+ raise ApiError("invalid_api_response", "not_started", server_request_id)
230
+ return payload
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class ApiError(Exception):
235
+ code: str
236
+ outcome: str
237
+ request_id: str
238
+
239
+
240
+ def successful(payload: dict[str, Any]) -> dict[str, Any]:
241
+ return payload
242
+
243
+
244
+ def failed(code: str, text: str, request_id: str | None = None, idempotency_key: str | None = None) -> CallToolResult:
245
+ structured: dict[str, Any] = {"code": code}
246
+ if request_id:
247
+ structured["request_id"] = request_id
248
+ if idempotency_key:
249
+ structured["retry_with_same_idempotency_key"] = idempotency_key
250
+ return CallToolResult(
251
+ content=[TextContent(type="text", text=text)],
252
+ structuredContent=structured,
253
+ isError=True,
254
+ )
255
+
256
+
257
+ def valid_space_slugs(value: list[str] | None) -> bool:
258
+ return value is None or all(
259
+ isinstance(slug, str) and 1 <= len(slug.strip()) <= 120 and SPACE_SLUG_PATTERN.fullmatch(slug.strip())
260
+ for slug in value
261
+ )
262
+
263
+
264
+ def valid_types(value: list[str] | None) -> bool:
265
+ return value is None or all(item in MEMORY_TYPES for item in value)
266
+
267
+
268
+ mcp = FastMCP("tracenor-mcp", json_response=True, log_level="ERROR")
269
+ # FastMCP delegates server metadata to the underlying protocol server; pin the
270
+ # public connector version instead of exposing the SDK version in initialize.
271
+ mcp._mcp_server.version = __version__
272
+
273
+
274
+ @mcp.tool()
275
+ async def get_active_context(
276
+ space_slugs: list[str] | None = None,
277
+ types: list[str] | None = None,
278
+ limit: int = 30,
279
+ ) -> Any:
280
+ """Get active memories allowed for this local repository context."""
281
+ if not valid_space_slugs(space_slugs) or not valid_types(types) or not isinstance(limit, int) or not 1 <= limit <= 50:
282
+ return failed("validation_error", "Provide valid input for this tool. Vault and repository selection are managed by Tracenor.")
283
+ return await execute_read("v1/context/active", {"space_slugs": space_slugs, "types": types, "limit": limit})
284
+
285
+
286
+ @mcp.tool()
287
+ async def search_memories(
288
+ query: str,
289
+ space_slugs: list[str] | None = None,
290
+ types: list[str] | None = None,
291
+ limit: int | None = None,
292
+ cursor: str | None = None,
293
+ ) -> Any:
294
+ """Search memories allowed for this local repository context."""
295
+ if (
296
+ not isinstance(query, str)
297
+ or not query.strip()
298
+ or not valid_space_slugs(space_slugs)
299
+ or not valid_types(types)
300
+ or (limit is not None and (not isinstance(limit, int) or not 1 <= limit <= 50))
301
+ or (cursor is not None and (not isinstance(cursor, str) or not cursor))
302
+ ):
303
+ return failed("validation_error", "Provide valid input for this tool. Vault and repository selection are managed by Tracenor.")
304
+ return await execute_read("v1/memories/search", {"query": query.strip(), "space_slugs": space_slugs, "types": types, "limit": limit, "cursor": cursor})
305
+
306
+
307
+ @mcp.tool()
308
+ async def propose_memory(space_slug: str, type: str, title: str, content: str, idempotency_key: str) -> Any:
309
+ """Propose confirmed, actionable memory for platform-selected review."""
310
+ return await execute_write("v1/memories/proposals", space_slug, type, title, content, idempotency_key)
311
+
312
+
313
+ @mcp.tool()
314
+ async def create_approved_memory(space_slug: str, type: str, title: str, content: str, idempotency_key: str) -> Any:
315
+ """Create an active memory only when it is already confirmed and authorized."""
316
+ return await execute_write("v1/memories", space_slug, type, title, content, idempotency_key)
317
+
318
+
319
+ async def execute_read(path: str, body: dict[str, Any]) -> dict[str, Any] | CallToolResult:
320
+ try:
321
+ config = load_config()
322
+ body = {key: value for key, value in body.items() if value is not None}
323
+ body["trusted_context"] = {"repository_detection": await detect_local_git_context()}
324
+ return successful(await ApiClient(config).request(path, body, False, str(uuid4())))
325
+ except ConfigError:
326
+ return failed("invalid_mcp_config", "The Tracenor MCP configuration is invalid.")
327
+ except ApiError as error:
328
+ return failed(error.code, read_error_message(error.code), error.request_id)
329
+ except Exception:
330
+ return failed("api_error", "The Tracenor request could not be completed.")
331
+
332
+
333
+ async def execute_write(path: str, space_slug: str, type: str, title: str, content: str, idempotency_key: str) -> dict[str, Any] | CallToolResult:
334
+ if not (
335
+ isinstance(space_slug, str)
336
+ and valid_space_slugs([space_slug])
337
+ and type in MEMORY_TYPES
338
+ and isinstance(title, str)
339
+ and 3 <= len(title.strip()) <= 120
340
+ and isinstance(content, str)
341
+ and 1 <= len(content) <= 2000
342
+ ):
343
+ return failed("validation_error", "Provide valid input for this tool. Vault and repository selection are managed by Tracenor.")
344
+ try:
345
+ UUID(idempotency_key)
346
+ except (ValueError, TypeError, AttributeError):
347
+ return failed("validation_error", "Provide valid input for this tool. Vault and repository selection are managed by Tracenor.")
348
+ try:
349
+ config = load_config()
350
+ body = {
351
+ "space_slug": space_slug.strip(),
352
+ "type": type,
353
+ "title": title.strip(),
354
+ "content": content,
355
+ "idempotency_key": idempotency_key,
356
+ "trusted_context": {"repository_detection": await detect_local_git_context()},
357
+ }
358
+ receipt = await ApiClient(config).request(path, body, True, idempotency_key)
359
+ if not valid_receipt(receipt):
360
+ return failed("invalid_api_response", "Tracenor did not return a valid write receipt.")
361
+ return successful(receipt)
362
+ except ConfigError:
363
+ return failed("invalid_mcp_config", "The Tracenor MCP configuration is invalid.")
364
+ except ApiError as error:
365
+ if error.outcome == "unknown":
366
+ return failed(error.code, "The write outcome is unknown. Retry only with the same idempotency key.", error.request_id, idempotency_key)
367
+ return failed(error.code, write_error_message(error.code), error.request_id)
368
+ except Exception:
369
+ return failed("api_error", "The write outcome is unknown. Retry only with the same idempotency key.", idempotency_key=idempotency_key)
370
+
371
+
372
+ def valid_receipt(receipt: dict[str, Any]) -> bool:
373
+ try:
374
+ UUID(str(receipt["memory_id"]))
375
+ UUID(str(receipt["request_id"]))
376
+ target = receipt["write_target"]
377
+ return (
378
+ receipt["status"] in MEMORY_STATUSES
379
+ and isinstance(target, dict)
380
+ and target["routing_source"] in {"repository_match", "api_key_default", "single_global_candidate"}
381
+ and all(UUID(str(target[key])) for key in ("vault_id", "space_id"))
382
+ )
383
+ except (KeyError, TypeError, ValueError):
384
+ return False
385
+
386
+
387
+ def read_error_message(code: str) -> str:
388
+ return {
389
+ "context_unavailable": "No memory context is available for this request.",
390
+ "invalid_api_key": "The configured Tracenor API key is invalid or no longer active.",
391
+ "request_timeout": "The Tracenor request timed out. You may retry this read.",
392
+ }.get(code, "The Tracenor request could not be completed.")
393
+
394
+
395
+ def write_error_message(code: str) -> str:
396
+ return {
397
+ "context_unavailable": "No memory write target is available for this request.",
398
+ "invalid_api_key": "The configured Tracenor API key is invalid or no longer active.",
399
+ }.get(code, "The Tracenor write could not be completed.")
400
+
401
+
402
+ def main() -> None:
403
+ try:
404
+ load_config()
405
+ mcp.run(transport="stdio")
406
+ except ConfigError:
407
+ sys.stderr.write("invalid_mcp_config\n")
408
+ raise SystemExit(1) from None
409
+ except Exception:
410
+ sys.stderr.write("mcp_start_failed\n")
411
+ raise SystemExit(1) from None