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.
- tracenor_mcp-1.0/.gitignore +153 -0
- tracenor_mcp-1.0/.python-version +1 -0
- tracenor_mcp-1.0/PKG-INFO +44 -0
- tracenor_mcp-1.0/README.md +26 -0
- tracenor_mcp-1.0/pyproject.toml +40 -0
- tracenor_mcp-1.0/src/tracenor_mcp/__init__.py +3 -0
- tracenor_mcp-1.0/src/tracenor_mcp/server.py +411 -0
- tracenor_mcp-1.0/tests/test_server.py +35 -0
- tracenor_mcp-1.0/uv.lock +797 -0
|
@@ -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,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
|