memuron 0.1.1__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.
- memuron/__init__.py +3 -0
- memuron/actions/__init__.py +12 -0
- memuron/actions/context.py +63 -0
- memuron/actions/helpers.py +88 -0
- memuron/actions/memory.py +340 -0
- memuron/actions/memory_write.py +290 -0
- memuron/actions/nodes.py +340 -0
- memuron/actions/registry.py +5 -0
- memuron/actions/runtime.py +37 -0
- memuron/actions/spaces_documents.py +720 -0
- memuron/actions/sync.py +155 -0
- memuron/application/__init__.py +1 -0
- memuron/application/api.py +206 -0
- memuron/application/app.py +103 -0
- memuron/application/capabilities.py +82 -0
- memuron/application/cli.py +35 -0
- memuron/application/config.py +176 -0
- memuron/application/mcp.py +44 -0
- memuron/application/mcp_oauth.py +290 -0
- memuron/application/registry.py +52 -0
- memuron/context.py +532 -0
- memuron/documents/__init__.py +1 -0
- memuron/documents/link_guardian.py +192 -0
- memuron/documents/linking.py +292 -0
- memuron/documents/parser.py +1152 -0
- memuron/documents/storage.py +151 -0
- memuron/documents/url_ingest.py +375 -0
- memuron/domain/__init__.py +1 -0
- memuron/domain/decoders.py +1 -0
- memuron/domain/encoders.py +185 -0
- memuron/domain/lifecycles.py +8 -0
- memuron/domain/limits.py +6 -0
- memuron/domain/representations.py +56 -0
- memuron/domain/schemas.py +581 -0
- memuron/domain/scope_filter.py +104 -0
- memuron/graphfs/__init__.py +1 -0
- memuron/graphfs/manual.py +635 -0
- memuron/graphfs/projection.py +578 -0
- memuron/graphfs/query.py +1782 -0
- memuron/graphfs/read_model.py +574 -0
- memuron/ingest/__init__.py +1 -0
- memuron/ingest/guardian.py +213 -0
- memuron/ingest/jobs.py +424 -0
- memuron/ingest/prompts.py +147 -0
- memuron/memory/__init__.py +1 -0
- memuron/memory/engine.py +35 -0
- memuron/memory/projections.py +452 -0
- memuron/memory/recipes.py +3247 -0
- memuron/persistence/__init__.py +1 -0
- memuron/persistence/db_pool.py +57 -0
- memuron/persistence/identity_store.py +918 -0
- memuron/persistence/store_helpers.py +16 -0
- memuron/search/__init__.py +1 -0
- memuron/search/fulltext.py +110 -0
- memuron/search/hybrid.py +284 -0
- memuron/search/pgvector.py +252 -0
- memuron/security/__init__.py +1 -0
- memuron/security/auth.py +143 -0
- memuron/security/auth_provider.py +119 -0
- memuron/security/authorization.py +53 -0
- memuron/security/clerk_scopes.py +94 -0
- memuron/security/clerk_webhooks.py +61 -0
- memuron/security/jwt_tokens.py +53 -0
- memuron/security/passwords.py +38 -0
- memuron/security/tenant.py +58 -0
- memuron/spaces/__init__.py +1 -0
- memuron/spaces/model.py +35 -0
- memuron/spaces/service.py +155 -0
- memuron/sync/__init__.py +25 -0
- memuron/sync/folder.py +828 -0
- memuron-0.1.1.dist-info/METADATA +242 -0
- memuron-0.1.1.dist-info/RECORD +74 -0
- memuron-0.1.1.dist-info/WHEEL +4 -0
- memuron-0.1.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Memuron configuration from environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
NOMIC_EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5-Q"
|
|
10
|
+
DEFAULT_GUARDIAN_MODEL = "inception/mercury-2"
|
|
11
|
+
# OpenRouter max_completion_tokens for inception/mercury-2 (reasoning model).
|
|
12
|
+
DEFAULT_GUARDIAN_MAX_TOKENS = 50_000
|
|
13
|
+
DEFAULT_GUARDIAN_TIMEOUT_SECONDS = 120
|
|
14
|
+
DEFAULT_DOCUMENT_LINK_MODEL = "google/gemini-3.1-flash-lite"
|
|
15
|
+
DEFAULT_IMAGE_VLM_MODEL = "perceptron/perceptron-mk1"
|
|
16
|
+
DEFAULT_JWT_ISSUER = "memuron"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_database_url() -> str:
|
|
20
|
+
"""Prefer explicit memuron config; fall back to Railway Postgres injects."""
|
|
21
|
+
url = (
|
|
22
|
+
os.environ.get("ARTHA_DATABASE_URL")
|
|
23
|
+
or os.environ.get("DATABASE_URL")
|
|
24
|
+
or os.environ.get("DATABASE_PRIVATE_URL")
|
|
25
|
+
or ""
|
|
26
|
+
).strip()
|
|
27
|
+
if not url and os.environ.get("RAILWAY_ENVIRONMENT"):
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
"Database URL missing. Set ARTHA_DATABASE_URL=${{ Postgres.DATABASE_URL }} "
|
|
30
|
+
"on the memuron Railway service."
|
|
31
|
+
)
|
|
32
|
+
return url
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Settings:
|
|
37
|
+
database_url: str
|
|
38
|
+
openrouter_api_key: str
|
|
39
|
+
guardian_model: str
|
|
40
|
+
guardian_retries: int
|
|
41
|
+
guardian_max_tokens: int
|
|
42
|
+
guardian_timeout_seconds: int
|
|
43
|
+
document_link_model: str
|
|
44
|
+
document_link_retries: int
|
|
45
|
+
document_chunk_neighbor_top_k: int
|
|
46
|
+
document_external_candidate_budget: int
|
|
47
|
+
search_min_semantic_score: float
|
|
48
|
+
search_hybrid: bool
|
|
49
|
+
search_rrf_k: int
|
|
50
|
+
search_retrieve_multiplier: int
|
|
51
|
+
ingest_worker_count: int
|
|
52
|
+
ingest_job_poll_interval_seconds: float
|
|
53
|
+
ingest_job_lease_seconds: int
|
|
54
|
+
ingest_job_max_attempts: int
|
|
55
|
+
embedder: str
|
|
56
|
+
embed_model: str
|
|
57
|
+
vector_dimensions: int
|
|
58
|
+
search_include_links: bool
|
|
59
|
+
describe_images: bool
|
|
60
|
+
image_vlm_model: str
|
|
61
|
+
image_vlm_timeout_seconds: int
|
|
62
|
+
api_key: str
|
|
63
|
+
jwt_secret: str
|
|
64
|
+
jwt_issuer: str
|
|
65
|
+
jwt_algorithm: str
|
|
66
|
+
jwt_expire_hours: int
|
|
67
|
+
auth_required: bool
|
|
68
|
+
object_storage_bucket: str = ""
|
|
69
|
+
object_storage_endpoint_url: str = ""
|
|
70
|
+
object_storage_region: str = "auto"
|
|
71
|
+
object_storage_access_key_id: str = ""
|
|
72
|
+
object_storage_secret_access_key: str = ""
|
|
73
|
+
object_storage_public_base_url: str = ""
|
|
74
|
+
object_storage_presign_seconds: int = 900
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_env(cls) -> "Settings":
|
|
78
|
+
guardian_model = (
|
|
79
|
+
os.environ.get("GUARDIAN_MODEL")
|
|
80
|
+
or os.environ.get("MEMURON_GUARDIAN_MODEL")
|
|
81
|
+
or DEFAULT_GUARDIAN_MODEL
|
|
82
|
+
)
|
|
83
|
+
jwt_secret = os.environ.get("MEMURON_JWT_SECRET", "").strip()
|
|
84
|
+
if not jwt_secret:
|
|
85
|
+
jwt_secret = secrets.token_urlsafe(32)
|
|
86
|
+
auth_required = os.environ.get("MEMURON_AUTH_REQUIRED", "1").lower() in {
|
|
87
|
+
"1",
|
|
88
|
+
"true",
|
|
89
|
+
"yes",
|
|
90
|
+
}
|
|
91
|
+
return cls(
|
|
92
|
+
database_url=resolve_database_url(),
|
|
93
|
+
openrouter_api_key=os.environ.get("OPENROUTER_API_KEY", ""),
|
|
94
|
+
guardian_model=guardian_model,
|
|
95
|
+
guardian_retries=int(os.environ.get("MEMURON_GUARDIAN_RETRIES", "3")),
|
|
96
|
+
guardian_max_tokens=int(
|
|
97
|
+
os.environ.get("MEMURON_GUARDIAN_MAX_TOKENS", str(DEFAULT_GUARDIAN_MAX_TOKENS))
|
|
98
|
+
),
|
|
99
|
+
guardian_timeout_seconds=int(
|
|
100
|
+
os.environ.get(
|
|
101
|
+
"MEMURON_GUARDIAN_TIMEOUT_SECONDS",
|
|
102
|
+
str(DEFAULT_GUARDIAN_TIMEOUT_SECONDS),
|
|
103
|
+
)
|
|
104
|
+
),
|
|
105
|
+
document_link_model=(
|
|
106
|
+
os.environ.get("MEMURON_DOCUMENT_LINK_MODEL")
|
|
107
|
+
or os.environ.get("DOCUMENT_LINK_MODEL")
|
|
108
|
+
or DEFAULT_DOCUMENT_LINK_MODEL
|
|
109
|
+
),
|
|
110
|
+
document_link_retries=int(
|
|
111
|
+
os.environ.get("MEMURON_DOCUMENT_LINK_RETRIES", "3")
|
|
112
|
+
),
|
|
113
|
+
document_chunk_neighbor_top_k=int(
|
|
114
|
+
os.environ.get("MEMURON_DOCUMENT_CHUNK_NEIGHBOR_TOP_K", "10")
|
|
115
|
+
),
|
|
116
|
+
document_external_candidate_budget=int(
|
|
117
|
+
os.environ.get("MEMURON_DOCUMENT_EXTERNAL_CANDIDATE_BUDGET", "30")
|
|
118
|
+
),
|
|
119
|
+
search_min_semantic_score=float(
|
|
120
|
+
os.environ.get("MEMURON_SEARCH_MIN_SEMANTIC_SCORE", "0.55")
|
|
121
|
+
),
|
|
122
|
+
search_hybrid=os.environ.get("MEMURON_SEARCH_HYBRID", "1").lower()
|
|
123
|
+
in {"1", "true", "yes"},
|
|
124
|
+
search_rrf_k=int(os.environ.get("MEMURON_SEARCH_RRF_K", "60")),
|
|
125
|
+
search_retrieve_multiplier=int(
|
|
126
|
+
os.environ.get("MEMURON_SEARCH_RETRIEVE_MULTIPLIER", "4")
|
|
127
|
+
),
|
|
128
|
+
ingest_worker_count=int(os.environ.get("MEMURON_INGEST_WORKER_COUNT", "2")),
|
|
129
|
+
ingest_job_poll_interval_seconds=float(
|
|
130
|
+
os.environ.get("MEMURON_INGEST_POLL_INTERVAL_SECONDS", "1.0")
|
|
131
|
+
),
|
|
132
|
+
ingest_job_lease_seconds=int(os.environ.get("MEMURON_INGEST_JOB_LEASE_SECONDS", "600")),
|
|
133
|
+
ingest_job_max_attempts=int(os.environ.get("MEMURON_INGEST_JOB_MAX_ATTEMPTS", "3")),
|
|
134
|
+
embedder=os.environ.get("ARTHA_EMBEDDER", "fastembed"),
|
|
135
|
+
embed_model=os.environ.get("ARTHA_EMBED_MODEL", NOMIC_EMBED_MODEL),
|
|
136
|
+
vector_dimensions=int(os.environ.get("MEMURON_VECTOR_DIMENSIONS", "768")),
|
|
137
|
+
search_include_links=os.environ.get("MEMURON_SEARCH_INCLUDE_LINKS", "1").lower()
|
|
138
|
+
in {"1", "true", "yes"},
|
|
139
|
+
describe_images=os.environ.get("MEMURON_DESCRIBE_IMAGES", "1").lower()
|
|
140
|
+
in {"1", "true", "yes"},
|
|
141
|
+
image_vlm_model=os.environ.get("MEMURON_IMAGE_VLM_MODEL", DEFAULT_IMAGE_VLM_MODEL),
|
|
142
|
+
image_vlm_timeout_seconds=int(os.environ.get("MEMURON_IMAGE_VLM_TIMEOUT_SECONDS", "60")),
|
|
143
|
+
api_key=os.environ.get("MEMURON_API_KEY", "").strip(),
|
|
144
|
+
jwt_secret=jwt_secret,
|
|
145
|
+
jwt_issuer=os.environ.get("MEMURON_JWT_ISSUER", DEFAULT_JWT_ISSUER),
|
|
146
|
+
jwt_algorithm=os.environ.get("MEMURON_JWT_ALGORITHM", "HS256"),
|
|
147
|
+
jwt_expire_hours=int(os.environ.get("MEMURON_JWT_EXPIRE_HOURS", "168")),
|
|
148
|
+
auth_required=auth_required,
|
|
149
|
+
object_storage_bucket=os.environ.get("MEMURON_OBJECT_STORAGE_BUCKET", "").strip(),
|
|
150
|
+
object_storage_endpoint_url=os.environ.get(
|
|
151
|
+
"MEMURON_OBJECT_STORAGE_ENDPOINT_URL",
|
|
152
|
+
os.environ.get("AWS_ENDPOINT_URL_S3", ""),
|
|
153
|
+
).strip(),
|
|
154
|
+
object_storage_region=os.environ.get(
|
|
155
|
+
"MEMURON_OBJECT_STORAGE_REGION",
|
|
156
|
+
os.environ.get("AWS_REGION", "auto"),
|
|
157
|
+
).strip(),
|
|
158
|
+
object_storage_access_key_id=os.environ.get(
|
|
159
|
+
"MEMURON_OBJECT_STORAGE_ACCESS_KEY_ID",
|
|
160
|
+
os.environ.get("AWS_ACCESS_KEY_ID", ""),
|
|
161
|
+
).strip(),
|
|
162
|
+
object_storage_secret_access_key=os.environ.get(
|
|
163
|
+
"MEMURON_OBJECT_STORAGE_SECRET_ACCESS_KEY",
|
|
164
|
+
os.environ.get("AWS_SECRET_ACCESS_KEY", ""),
|
|
165
|
+
).strip(),
|
|
166
|
+
object_storage_public_base_url=os.environ.get(
|
|
167
|
+
"MEMURON_OBJECT_STORAGE_PUBLIC_BASE_URL",
|
|
168
|
+
"",
|
|
169
|
+
).strip().rstrip("/"),
|
|
170
|
+
object_storage_presign_seconds=int(
|
|
171
|
+
os.environ.get("MEMURON_OBJECT_STORAGE_PRESIGN_SECONDS", "900")
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
settings = Settings.from_env()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Memuron MCP stdio server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from artha_engine.app.mcp import run_mcp
|
|
8
|
+
from artha_engine.runtime.auth import AuthContext
|
|
9
|
+
|
|
10
|
+
from memuron.application.app import close_application, configure_clerk_env, create_application
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
configure_clerk_env()
|
|
15
|
+
tenant_id = os.environ.get("MEMURON_MCP_TENANT_ID", "").strip()
|
|
16
|
+
actor_id = os.environ.get("MEMURON_MCP_ACTOR_ID", "").strip()
|
|
17
|
+
if not tenant_id or not actor_id:
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
"Set MEMURON_MCP_TENANT_ID and MEMURON_MCP_ACTOR_ID so MCP tools "
|
|
20
|
+
"operate inside one explicit Memuron organization and user."
|
|
21
|
+
)
|
|
22
|
+
scopes = [
|
|
23
|
+
item.strip()
|
|
24
|
+
for item in os.environ.get(
|
|
25
|
+
"MEMURON_MCP_SCOPES",
|
|
26
|
+
"memory:read,memory:write,memory:delete,space:admin",
|
|
27
|
+
).split(",")
|
|
28
|
+
if item.strip()
|
|
29
|
+
]
|
|
30
|
+
auth = AuthContext(
|
|
31
|
+
actor_id=actor_id,
|
|
32
|
+
tenant_id=tenant_id,
|
|
33
|
+
scopes=scopes,
|
|
34
|
+
claims={"auth_scheme": "local_mcp"},
|
|
35
|
+
)
|
|
36
|
+
application = create_application()
|
|
37
|
+
try:
|
|
38
|
+
run_mcp(application, auth=auth)
|
|
39
|
+
finally:
|
|
40
|
+
close_application(application)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Clerk OAuth authentication for the remote Streamable HTTP MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
14
|
+
from mcp.server.auth.provider import AccessToken
|
|
15
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
16
|
+
|
|
17
|
+
from artha_engine.app.auth import AuthenticationError, JwtAuthProvider
|
|
18
|
+
from artha_engine.app.mcp import create_mcp_server
|
|
19
|
+
from artha_engine.runtime.auth import AuthContext
|
|
20
|
+
|
|
21
|
+
from memuron.application.app import configure_clerk_env
|
|
22
|
+
from memuron.security.clerk_scopes import MEMURON_MEMBER_SCOPES, MEMURON_OWNER_SCOPES
|
|
23
|
+
|
|
24
|
+
OAUTH_REQUIRED_SCOPES = ["profile"]
|
|
25
|
+
MCP_TOOL_SECURITY_SCHEMES = [{"type": "oauth2", "scopes": OAUTH_REQUIRED_SCOPES}]
|
|
26
|
+
MCP_INSTRUCTIONS = """\
|
|
27
|
+
Memuron is an agent memory system organized as spaces over a semantic graph.
|
|
28
|
+
Start with memuron_list_spaces, then use memuron_query with a cwd such as
|
|
29
|
+
/spaces/space.personal. Prefer head/select/preview before fetching full content.
|
|
30
|
+
Use memuron_get only after discovering an ID; its output is bounded by default.
|
|
31
|
+
Space arguments accept UUIDs, slugs, tokens such as space.work, or paths such as
|
|
32
|
+
/spaces/space.work. memuron_ingest is asynchronous, so poll memuron_job_status.
|
|
33
|
+
Use memuron_document_source with a document, chunk, image, or document collection
|
|
34
|
+
node ID to resolve the original uploaded file and short-lived download URL.
|
|
35
|
+
Call memuron_help with overview, compact, commands, graph, or errors when unsure.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def protected_resource_metadata_path(public_url: str) -> str:
|
|
40
|
+
resource_url = public_url.rstrip("/")
|
|
41
|
+
parsed = urlparse(resource_url)
|
|
42
|
+
resource_path = parsed.path if parsed.path != "/" else ""
|
|
43
|
+
return f"/.well-known/oauth-protected-resource{resource_path}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def protected_resource_metadata_url(public_url: str) -> str:
|
|
47
|
+
resource_url = public_url.rstrip("/")
|
|
48
|
+
parsed = urlparse(resource_url)
|
|
49
|
+
return (
|
|
50
|
+
f"{parsed.scheme}://{parsed.netloc}"
|
|
51
|
+
f"{protected_resource_metadata_path(resource_url)}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def protected_resource_metadata(public_url: str) -> dict[str, Any]:
|
|
56
|
+
resource_url = public_url.rstrip("/")
|
|
57
|
+
issuer = os.environ["CLERK_ISSUER"].rstrip("/")
|
|
58
|
+
return {
|
|
59
|
+
"resource": resource_url,
|
|
60
|
+
"authorization_servers": [issuer],
|
|
61
|
+
"scopes_supported": OAUTH_REQUIRED_SCOPES,
|
|
62
|
+
"bearer_methods_supported": ["header"],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mcp_auth_challenge(public_url: str) -> str:
|
|
67
|
+
scope = " ".join(OAUTH_REQUIRED_SCOPES)
|
|
68
|
+
metadata_url = protected_resource_metadata_url(public_url)
|
|
69
|
+
return (
|
|
70
|
+
f'Bearer resource_metadata="{metadata_url}", '
|
|
71
|
+
f'scope="{scope}", '
|
|
72
|
+
'error="invalid_token", '
|
|
73
|
+
'error_description="Sign in to Memuron to continue"'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _org_aliases() -> dict[str, str]:
|
|
78
|
+
raw = os.environ.get("MEMURON_CLERK_ORG_ALIASES", "").strip()
|
|
79
|
+
if not raw:
|
|
80
|
+
return {}
|
|
81
|
+
try:
|
|
82
|
+
payload = json.loads(raw)
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
raise RuntimeError("MEMURON_CLERK_ORG_ALIASES must be a JSON object") from exc
|
|
85
|
+
if not isinstance(payload, dict):
|
|
86
|
+
raise RuntimeError("MEMURON_CLERK_ORG_ALIASES must be a JSON object")
|
|
87
|
+
return {str(key): str(value) for key, value in payload.items()}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _preferred_clerk_org_id() -> str | None:
|
|
91
|
+
return os.environ.get("MEMURON_MCP_DEFAULT_CLERK_ORG_ID", "").strip() or None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ClerkOAuthTokenVerifier:
|
|
96
|
+
"""Verify Clerk OAuth access tokens and map them into Memuron tenancy."""
|
|
97
|
+
|
|
98
|
+
role_cache_seconds: int = 300
|
|
99
|
+
_jwt: JwtAuthProvider = field(init=False)
|
|
100
|
+
_organizations: dict[str, tuple[float, list[tuple[str, str]]]] = field(
|
|
101
|
+
default_factory=dict,
|
|
102
|
+
init=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
configure_clerk_env()
|
|
107
|
+
self._jwt = JwtAuthProvider(
|
|
108
|
+
issuer=os.environ.get("CLERK_ISSUER"),
|
|
109
|
+
jwks_url=os.environ.get("CLERK_JWKS_URL"),
|
|
110
|
+
audience=os.environ.get("MEMURON_MCP_OAUTH_AUDIENCE") or None,
|
|
111
|
+
env_prefix="CLERK",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
115
|
+
try:
|
|
116
|
+
verified = await self._jwt.authenticate_token(token)
|
|
117
|
+
except AuthenticationError:
|
|
118
|
+
return None
|
|
119
|
+
actor_id = verified.actor_id
|
|
120
|
+
if not actor_id:
|
|
121
|
+
return None
|
|
122
|
+
oauth_scopes = list(verified.scopes)
|
|
123
|
+
if "profile" not in oauth_scopes:
|
|
124
|
+
return None
|
|
125
|
+
organization = await self._organization_context(
|
|
126
|
+
str(actor_id),
|
|
127
|
+
str(verified.tenant_id) if verified.tenant_id else None,
|
|
128
|
+
)
|
|
129
|
+
if organization is None:
|
|
130
|
+
return None
|
|
131
|
+
clerk_org_id, role = organization
|
|
132
|
+
memuron_scopes = (
|
|
133
|
+
list(MEMURON_OWNER_SCOPES)
|
|
134
|
+
if role in {"org:admin", "admin", "owner"}
|
|
135
|
+
else list(MEMURON_MEMBER_SCOPES)
|
|
136
|
+
)
|
|
137
|
+
tenant_id = _org_aliases().get(str(clerk_org_id), str(clerk_org_id))
|
|
138
|
+
claims = dict(verified.claims)
|
|
139
|
+
claims.update(
|
|
140
|
+
{
|
|
141
|
+
"auth_scheme": "clerk_oauth",
|
|
142
|
+
"clerk_org_id": str(clerk_org_id),
|
|
143
|
+
"memuron_tenant_id": tenant_id,
|
|
144
|
+
"memuron_scopes": memuron_scopes,
|
|
145
|
+
"org_role": role,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
return AccessToken(
|
|
149
|
+
token=token,
|
|
150
|
+
client_id=str(claims.get("azp") or claims.get("client_id") or "mcp-client"),
|
|
151
|
+
scopes=[*oauth_scopes, *[scope for scope in memuron_scopes if scope not in oauth_scopes]],
|
|
152
|
+
expires_at=int(claims["exp"]) if claims.get("exp") is not None else None,
|
|
153
|
+
subject=str(actor_id),
|
|
154
|
+
claims=claims,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def _organization_context(
|
|
158
|
+
self,
|
|
159
|
+
user_id: str,
|
|
160
|
+
claimed_org_id: str | None,
|
|
161
|
+
) -> tuple[str, str] | None:
|
|
162
|
+
memberships = await self._organization_memberships(user_id)
|
|
163
|
+
aliases = _org_aliases()
|
|
164
|
+
if claimed_org_id:
|
|
165
|
+
for org_id, role in memberships:
|
|
166
|
+
if org_id == claimed_org_id:
|
|
167
|
+
return org_id, role
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
configured = [
|
|
171
|
+
(org_id, role) for org_id, role in memberships if org_id in aliases
|
|
172
|
+
]
|
|
173
|
+
if len(configured) == 1:
|
|
174
|
+
return configured[0]
|
|
175
|
+
preferred = _preferred_clerk_org_id()
|
|
176
|
+
if preferred:
|
|
177
|
+
for org_id, role in configured or memberships:
|
|
178
|
+
if org_id == preferred:
|
|
179
|
+
return org_id, role
|
|
180
|
+
if not aliases and len(memberships) == 1:
|
|
181
|
+
return memberships[0]
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
async def _organization_memberships(self, user_id: str) -> list[tuple[str, str]]:
|
|
185
|
+
cached = self._organizations.get(user_id)
|
|
186
|
+
now = time.monotonic()
|
|
187
|
+
if cached and cached[0] > now:
|
|
188
|
+
return cached[1]
|
|
189
|
+
secret = os.environ.get("CLERK_SECRET_KEY", "").strip()
|
|
190
|
+
if not secret:
|
|
191
|
+
return []
|
|
192
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
193
|
+
response = await client.get(
|
|
194
|
+
f"https://api.clerk.com/v1/users/{user_id}/organization_memberships",
|
|
195
|
+
params={"limit": 100},
|
|
196
|
+
headers={"Authorization": f"Bearer {secret}"},
|
|
197
|
+
)
|
|
198
|
+
response.raise_for_status()
|
|
199
|
+
memberships = []
|
|
200
|
+
for membership in response.json().get("data", []):
|
|
201
|
+
org_id = (membership.get("organization") or {}).get("id")
|
|
202
|
+
if org_id:
|
|
203
|
+
memberships.append(
|
|
204
|
+
(str(org_id), str(membership.get("role") or "org:member"))
|
|
205
|
+
)
|
|
206
|
+
self._organizations[user_id] = (
|
|
207
|
+
now + self.role_cache_seconds,
|
|
208
|
+
memberships,
|
|
209
|
+
)
|
|
210
|
+
return memberships
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def current_mcp_auth() -> AuthContext:
|
|
214
|
+
access_token = get_access_token()
|
|
215
|
+
return _auth_context_from_access_token(access_token)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def current_mcp_request_auth(
|
|
219
|
+
mcp_context: Any,
|
|
220
|
+
verifier: ClerkOAuthTokenVerifier,
|
|
221
|
+
) -> AuthContext:
|
|
222
|
+
token = _bearer_token_from_mcp_context(mcp_context)
|
|
223
|
+
if token is None:
|
|
224
|
+
raise AuthenticationError("Missing OAuth bearer token")
|
|
225
|
+
access_token = await verifier.verify_token(token)
|
|
226
|
+
return _auth_context_from_access_token(access_token)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _auth_context_from_access_token(access_token: AccessToken | None) -> AuthContext:
|
|
230
|
+
if access_token is None or access_token.subject is None:
|
|
231
|
+
raise AuthenticationError("Missing authenticated MCP user")
|
|
232
|
+
claims = dict(access_token.claims or {})
|
|
233
|
+
tenant_id = claims.get("memuron_tenant_id")
|
|
234
|
+
scopes = claims.get("memuron_scopes")
|
|
235
|
+
if not tenant_id or not isinstance(scopes, list):
|
|
236
|
+
raise AuthenticationError("MCP token is missing Memuron organization context")
|
|
237
|
+
return AuthContext(
|
|
238
|
+
actor_id=access_token.subject,
|
|
239
|
+
tenant_id=str(tenant_id),
|
|
240
|
+
scopes=[str(scope) for scope in scopes],
|
|
241
|
+
claims=claims,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _bearer_token_from_mcp_context(mcp_context: Any) -> str | None:
|
|
246
|
+
try:
|
|
247
|
+
request_context = getattr(mcp_context, "request_context", None)
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
250
|
+
request = getattr(request_context, "request", None)
|
|
251
|
+
headers = getattr(request, "headers", None)
|
|
252
|
+
if headers is None:
|
|
253
|
+
return None
|
|
254
|
+
value = headers.get("authorization") or headers.get("Authorization")
|
|
255
|
+
if not isinstance(value, str):
|
|
256
|
+
return None
|
|
257
|
+
scheme, _, token = value.partition(" ")
|
|
258
|
+
if scheme.lower() != "bearer" or not token.strip():
|
|
259
|
+
return None
|
|
260
|
+
return token.strip()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def create_remote_mcp_server(application: Any, public_url: str) -> Any:
|
|
264
|
+
resource_url = public_url.rstrip("/")
|
|
265
|
+
public_origin = urlparse(resource_url)
|
|
266
|
+
public_host = public_origin.netloc
|
|
267
|
+
allowed_origins = (
|
|
268
|
+
[f"{public_origin.scheme}://{public_host}"]
|
|
269
|
+
if public_origin.scheme and public_host
|
|
270
|
+
else []
|
|
271
|
+
)
|
|
272
|
+
verifier = ClerkOAuthTokenVerifier()
|
|
273
|
+
|
|
274
|
+
async def auth_resolver(mcp_context: Any) -> AuthContext:
|
|
275
|
+
return await current_mcp_request_auth(mcp_context, verifier)
|
|
276
|
+
|
|
277
|
+
return create_mcp_server(
|
|
278
|
+
application,
|
|
279
|
+
auth_resolver=auth_resolver,
|
|
280
|
+
transport_security=TransportSecuritySettings(
|
|
281
|
+
enable_dns_rebinding_protection=True,
|
|
282
|
+
allowed_hosts=[public_host],
|
|
283
|
+
allowed_origins=allowed_origins,
|
|
284
|
+
),
|
|
285
|
+
instructions=MCP_INSTRUCTIONS,
|
|
286
|
+
stateless_http=True,
|
|
287
|
+
tool_security_schemes=MCP_TOOL_SECURITY_SCHEMES,
|
|
288
|
+
auth_challenge=mcp_auth_challenge(resource_url),
|
|
289
|
+
structured_output=False,
|
|
290
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from artha_engine import ComponentRegistry, build_core_registry
|
|
2
|
+
|
|
3
|
+
from memuron.domain.encoders import (
|
|
4
|
+
MemoryEncoder,
|
|
5
|
+
MemoryLinkEncoder,
|
|
6
|
+
MemoryPlacementEncoder,
|
|
7
|
+
normalize_memory_encoder_input,
|
|
8
|
+
normalize_memory_link_encoder_input,
|
|
9
|
+
normalize_memory_placement_encoder_input,
|
|
10
|
+
)
|
|
11
|
+
from memuron.graphfs.projection import FilesystemProjection
|
|
12
|
+
from memuron.memory.projections import MemoryLinkProjection, MemoryPlacementProjection, MemoryProjection
|
|
13
|
+
from memuron.domain.representations import MemoryArthaanu, MemoryLinkArthaanu, MemoryPlacementArthaanu
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _memory_encoder_factory(_params, embedder=None, **_kwargs):
|
|
17
|
+
return MemoryEncoder(embedder=embedder)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _memory_link_encoder_factory(_params, embedder=None, **_kwargs):
|
|
21
|
+
return MemoryLinkEncoder(embedder=embedder)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _memory_placement_encoder_factory(_params, **_kwargs):
|
|
25
|
+
return MemoryPlacementEncoder()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_registry() -> ComponentRegistry:
|
|
29
|
+
registry = build_core_registry()
|
|
30
|
+
registry.register_arthaanu_type("memory", MemoryArthaanu)
|
|
31
|
+
registry.register_arthaanu_type("memory_link", MemoryLinkArthaanu)
|
|
32
|
+
registry.register_arthaanu_type("memory_placement", MemoryPlacementArthaanu)
|
|
33
|
+
registry.register_encoder(
|
|
34
|
+
"memory",
|
|
35
|
+
_memory_encoder_factory,
|
|
36
|
+
normalize_input=normalize_memory_encoder_input,
|
|
37
|
+
)
|
|
38
|
+
registry.register_encoder(
|
|
39
|
+
"memory_link",
|
|
40
|
+
_memory_link_encoder_factory,
|
|
41
|
+
normalize_input=normalize_memory_link_encoder_input,
|
|
42
|
+
)
|
|
43
|
+
registry.register_encoder(
|
|
44
|
+
"memory_placement",
|
|
45
|
+
_memory_placement_encoder_factory,
|
|
46
|
+
normalize_input=normalize_memory_placement_encoder_input,
|
|
47
|
+
)
|
|
48
|
+
registry.register_projection("memuron_memories", lambda: MemoryProjection())
|
|
49
|
+
registry.register_projection("memuron_links", lambda: MemoryLinkProjection())
|
|
50
|
+
registry.register_projection("memuron_placements", lambda: MemoryPlacementProjection())
|
|
51
|
+
registry.register_projection("memuron_fs", lambda: FilesystemProjection())
|
|
52
|
+
return registry
|