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.
Files changed (74) hide show
  1. memuron/__init__.py +3 -0
  2. memuron/actions/__init__.py +12 -0
  3. memuron/actions/context.py +63 -0
  4. memuron/actions/helpers.py +88 -0
  5. memuron/actions/memory.py +340 -0
  6. memuron/actions/memory_write.py +290 -0
  7. memuron/actions/nodes.py +340 -0
  8. memuron/actions/registry.py +5 -0
  9. memuron/actions/runtime.py +37 -0
  10. memuron/actions/spaces_documents.py +720 -0
  11. memuron/actions/sync.py +155 -0
  12. memuron/application/__init__.py +1 -0
  13. memuron/application/api.py +206 -0
  14. memuron/application/app.py +103 -0
  15. memuron/application/capabilities.py +82 -0
  16. memuron/application/cli.py +35 -0
  17. memuron/application/config.py +176 -0
  18. memuron/application/mcp.py +44 -0
  19. memuron/application/mcp_oauth.py +290 -0
  20. memuron/application/registry.py +52 -0
  21. memuron/context.py +532 -0
  22. memuron/documents/__init__.py +1 -0
  23. memuron/documents/link_guardian.py +192 -0
  24. memuron/documents/linking.py +292 -0
  25. memuron/documents/parser.py +1152 -0
  26. memuron/documents/storage.py +151 -0
  27. memuron/documents/url_ingest.py +375 -0
  28. memuron/domain/__init__.py +1 -0
  29. memuron/domain/decoders.py +1 -0
  30. memuron/domain/encoders.py +185 -0
  31. memuron/domain/lifecycles.py +8 -0
  32. memuron/domain/limits.py +6 -0
  33. memuron/domain/representations.py +56 -0
  34. memuron/domain/schemas.py +581 -0
  35. memuron/domain/scope_filter.py +104 -0
  36. memuron/graphfs/__init__.py +1 -0
  37. memuron/graphfs/manual.py +635 -0
  38. memuron/graphfs/projection.py +578 -0
  39. memuron/graphfs/query.py +1782 -0
  40. memuron/graphfs/read_model.py +574 -0
  41. memuron/ingest/__init__.py +1 -0
  42. memuron/ingest/guardian.py +213 -0
  43. memuron/ingest/jobs.py +424 -0
  44. memuron/ingest/prompts.py +147 -0
  45. memuron/memory/__init__.py +1 -0
  46. memuron/memory/engine.py +35 -0
  47. memuron/memory/projections.py +452 -0
  48. memuron/memory/recipes.py +3247 -0
  49. memuron/persistence/__init__.py +1 -0
  50. memuron/persistence/db_pool.py +57 -0
  51. memuron/persistence/identity_store.py +918 -0
  52. memuron/persistence/store_helpers.py +16 -0
  53. memuron/search/__init__.py +1 -0
  54. memuron/search/fulltext.py +110 -0
  55. memuron/search/hybrid.py +284 -0
  56. memuron/search/pgvector.py +252 -0
  57. memuron/security/__init__.py +1 -0
  58. memuron/security/auth.py +143 -0
  59. memuron/security/auth_provider.py +119 -0
  60. memuron/security/authorization.py +53 -0
  61. memuron/security/clerk_scopes.py +94 -0
  62. memuron/security/clerk_webhooks.py +61 -0
  63. memuron/security/jwt_tokens.py +53 -0
  64. memuron/security/passwords.py +38 -0
  65. memuron/security/tenant.py +58 -0
  66. memuron/spaces/__init__.py +1 -0
  67. memuron/spaces/model.py +35 -0
  68. memuron/spaces/service.py +155 -0
  69. memuron/sync/__init__.py +25 -0
  70. memuron/sync/folder.py +828 -0
  71. memuron-0.1.1.dist-info/METADATA +242 -0
  72. memuron-0.1.1.dist-info/RECORD +74 -0
  73. memuron-0.1.1.dist-info/WHEEL +4 -0
  74. 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