remdb 0.3.141__py3-none-any.whl → 0.3.163__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (44) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +310 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +18 -3
  5. rem/api/deps.py +3 -5
  6. rem/api/main.py +22 -3
  7. rem/api/mcp_router/server.py +2 -0
  8. rem/api/mcp_router/tools.py +90 -0
  9. rem/api/middleware/tracking.py +5 -5
  10. rem/api/routers/auth.py +346 -5
  11. rem/api/routers/chat/completions.py +4 -2
  12. rem/api/routers/chat/streaming.py +77 -22
  13. rem/api/routers/messages.py +24 -15
  14. rem/auth/__init__.py +13 -3
  15. rem/auth/jwt.py +352 -0
  16. rem/auth/middleware.py +108 -6
  17. rem/auth/providers/__init__.py +4 -1
  18. rem/auth/providers/email.py +215 -0
  19. rem/cli/commands/experiments.py +32 -46
  20. rem/models/core/experiment.py +4 -14
  21. rem/models/entities/__init__.py +4 -0
  22. rem/models/entities/subscriber.py +175 -0
  23. rem/models/entities/user.py +1 -0
  24. rem/schemas/agents/core/agent-builder.yaml +134 -0
  25. rem/services/__init__.py +3 -1
  26. rem/services/content/service.py +4 -3
  27. rem/services/email/__init__.py +10 -0
  28. rem/services/email/service.py +511 -0
  29. rem/services/email/templates.py +360 -0
  30. rem/services/postgres/README.md +38 -0
  31. rem/services/postgres/diff_service.py +19 -3
  32. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  33. rem/services/postgres/repository.py +5 -4
  34. rem/services/session/compression.py +113 -50
  35. rem/services/session/reload.py +14 -7
  36. rem/services/user_service.py +29 -0
  37. rem/settings.py +199 -4
  38. rem/sql/migrations/005_schema_update.sql +145 -0
  39. rem/utils/README.md +45 -0
  40. rem/utils/files.py +157 -1
  41. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/METADATA +7 -5
  42. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/RECORD +44 -35
  43. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/WHEEL +0 -0
  44. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/entry_points.txt +0 -0
@@ -6,6 +6,8 @@ Use create_agent_from_schema_file() to instantiate agents.
6
6
 
7
7
  The SSE Simulator is a special programmatic "agent" that generates
8
8
  scripted SSE events for testing and demonstration - it doesn't use an LLM.
9
+
10
+ Agent Manager provides functions for saving/loading user-created agents.
9
11
  """
10
12
 
11
13
  from .sse_simulator import (
@@ -14,9 +16,23 @@ from .sse_simulator import (
14
16
  stream_error_demo,
15
17
  )
16
18
 
19
+ from .agent_manager import (
20
+ save_agent,
21
+ get_agent,
22
+ list_agents,
23
+ delete_agent,
24
+ build_agent_spec,
25
+ )
26
+
17
27
  __all__ = [
18
28
  # SSE Simulator (programmatic, no LLM)
19
29
  "stream_simulator_events",
20
30
  "stream_minimal_demo",
21
31
  "stream_error_demo",
32
+ # Agent Manager
33
+ "save_agent",
34
+ "get_agent",
35
+ "list_agents",
36
+ "delete_agent",
37
+ "build_agent_spec",
22
38
  ]
@@ -0,0 +1,310 @@
1
+ """
2
+ Agent Manager - Save, load, and manage user-created agents.
3
+
4
+ This module provides the core functionality for persisting agent schemas
5
+ to the database with user scoping.
6
+
7
+ Usage:
8
+ from rem.agentic.agents.agent_manager import save_agent, get_agent, list_agents
9
+
10
+ # Save an agent
11
+ result = await save_agent(
12
+ name="my-assistant",
13
+ description="You are a helpful assistant.",
14
+ user_id="user-123"
15
+ )
16
+
17
+ # Get an agent
18
+ agent = await get_agent("my-assistant", user_id="user-123")
19
+
20
+ # List user's agents
21
+ agents = await list_agents(user_id="user-123")
22
+ """
23
+
24
+ from typing import Any
25
+ from loguru import logger
26
+
27
+
28
+ DEFAULT_TOOLS = ["search_rem", "register_metadata"]
29
+
30
+
31
+ def build_agent_spec(
32
+ name: str,
33
+ description: str,
34
+ properties: dict[str, Any] | None = None,
35
+ required: list[str] | None = None,
36
+ tools: list[str] | None = None,
37
+ tags: list[str] | None = None,
38
+ version: str = "1.0.0",
39
+ ) -> dict[str, Any]:
40
+ """
41
+ Build a valid agent schema spec.
42
+
43
+ Args:
44
+ name: Agent name in kebab-case
45
+ description: System prompt for the agent
46
+ properties: Output schema properties
47
+ required: Required property names
48
+ tools: Tool names (defaults to search_rem, register_metadata)
49
+ tags: Categorization tags
50
+ version: Semantic version
51
+
52
+ Returns:
53
+ Valid agent schema spec dict
54
+ """
55
+ # Default properties
56
+ if properties is None:
57
+ properties = {
58
+ "answer": {
59
+ "type": "string",
60
+ "description": "Natural language response to the user"
61
+ }
62
+ }
63
+
64
+ # Default required
65
+ if required is None:
66
+ required = ["answer"]
67
+
68
+ # Default tools
69
+ if tools is None:
70
+ tools = DEFAULT_TOOLS.copy()
71
+
72
+ return {
73
+ "type": "object",
74
+ "description": description,
75
+ "properties": properties,
76
+ "required": required,
77
+ "json_schema_extra": {
78
+ "kind": "agent",
79
+ "name": name,
80
+ "version": version,
81
+ "tags": tags or [],
82
+ "tools": [{"name": t, "description": f"Tool: {t}"} for t in tools],
83
+ }
84
+ }
85
+
86
+
87
+ async def save_agent(
88
+ name: str,
89
+ description: str,
90
+ user_id: str,
91
+ properties: dict[str, Any] | None = None,
92
+ required: list[str] | None = None,
93
+ tools: list[str] | None = None,
94
+ tags: list[str] | None = None,
95
+ version: str = "1.0.0",
96
+ ) -> dict[str, Any]:
97
+ """
98
+ Save an agent schema to the database.
99
+
100
+ Args:
101
+ name: Agent name in kebab-case (e.g., "code-reviewer")
102
+ description: The agent's system prompt
103
+ user_id: User identifier for scoping
104
+ properties: Output schema properties
105
+ required: Required property names
106
+ tools: Tool names
107
+ tags: Categorization tags
108
+ version: Semantic version
109
+
110
+ Returns:
111
+ Dict with status, agent_name, version, message
112
+
113
+ Raises:
114
+ RuntimeError: If database is not available
115
+ """
116
+ from rem.models.entities import Schema
117
+ from rem.services.postgres import get_postgres_service
118
+
119
+ # Build the spec
120
+ spec = build_agent_spec(
121
+ name=name,
122
+ description=description,
123
+ properties=properties,
124
+ required=required,
125
+ tools=tools,
126
+ tags=tags,
127
+ version=version,
128
+ )
129
+
130
+ # Create Schema entity (user-scoped)
131
+ schema_entity = Schema(
132
+ tenant_id=user_id,
133
+ user_id=user_id,
134
+ name=name,
135
+ spec=spec,
136
+ category="agent",
137
+ metadata={
138
+ "version": version,
139
+ "tags": tags or [],
140
+ "created_via": "agent_manager",
141
+ },
142
+ )
143
+
144
+ # Save to database
145
+ postgres = get_postgres_service()
146
+ if not postgres:
147
+ raise RuntimeError("Database not available")
148
+
149
+ await postgres.connect()
150
+ try:
151
+ await postgres.batch_upsert(
152
+ records=[schema_entity],
153
+ model=Schema,
154
+ table_name="schemas",
155
+ entity_key_field="name",
156
+ generate_embeddings=False,
157
+ )
158
+ logger.info(f"✅ Agent saved: {name} (user={user_id}, version={version})")
159
+ finally:
160
+ await postgres.disconnect()
161
+
162
+ return {
163
+ "status": "success",
164
+ "agent_name": name,
165
+ "version": version,
166
+ "message": f"Agent '{name}' saved successfully.",
167
+ }
168
+
169
+
170
+ async def get_agent(
171
+ name: str,
172
+ user_id: str,
173
+ ) -> dict[str, Any] | None:
174
+ """
175
+ Get an agent schema by name.
176
+
177
+ Checks user's schemas first, then falls back to system schemas.
178
+
179
+ Args:
180
+ name: Agent name
181
+ user_id: User identifier
182
+
183
+ Returns:
184
+ Agent spec dict if found, None otherwise
185
+ """
186
+ from rem.services.postgres import get_postgres_service
187
+
188
+ postgres = get_postgres_service()
189
+ if not postgres:
190
+ return None
191
+
192
+ await postgres.connect()
193
+ try:
194
+ query = """
195
+ SELECT spec FROM schemas
196
+ WHERE LOWER(name) = LOWER($1)
197
+ AND category = 'agent'
198
+ AND (user_id = $2 OR user_id IS NULL OR tenant_id = 'system')
199
+ ORDER BY CASE WHEN user_id = $2 THEN 0 ELSE 1 END
200
+ LIMIT 1
201
+ """
202
+ row = await postgres.fetchrow(query, name, user_id)
203
+ if row:
204
+ return row["spec"]
205
+ return None
206
+ finally:
207
+ await postgres.disconnect()
208
+
209
+
210
+ async def list_agents(
211
+ user_id: str,
212
+ include_system: bool = True,
213
+ ) -> list[dict[str, Any]]:
214
+ """
215
+ List available agents for a user.
216
+
217
+ Args:
218
+ user_id: User identifier
219
+ include_system: Include system agents
220
+
221
+ Returns:
222
+ List of agent metadata dicts
223
+ """
224
+ from rem.services.postgres import get_postgres_service
225
+
226
+ postgres = get_postgres_service()
227
+ if not postgres:
228
+ return []
229
+
230
+ await postgres.connect()
231
+ try:
232
+ if include_system:
233
+ query = """
234
+ SELECT name, metadata, user_id, tenant_id
235
+ FROM schemas
236
+ WHERE category = 'agent'
237
+ AND (user_id = $1 OR user_id IS NULL OR tenant_id = 'system')
238
+ ORDER BY name
239
+ """
240
+ rows = await postgres.fetch(query, user_id)
241
+ else:
242
+ query = """
243
+ SELECT name, metadata, user_id, tenant_id
244
+ FROM schemas
245
+ WHERE category = 'agent'
246
+ AND user_id = $1
247
+ ORDER BY name
248
+ """
249
+ rows = await postgres.fetch(query, user_id)
250
+
251
+ return [
252
+ {
253
+ "name": row["name"],
254
+ "version": row["metadata"].get("version", "1.0.0") if row["metadata"] else "1.0.0",
255
+ "tags": row["metadata"].get("tags", []) if row["metadata"] else [],
256
+ "is_system": row["tenant_id"] == "system" or row["user_id"] is None,
257
+ }
258
+ for row in rows
259
+ ]
260
+ finally:
261
+ await postgres.disconnect()
262
+
263
+
264
+ async def delete_agent(
265
+ name: str,
266
+ user_id: str,
267
+ ) -> dict[str, Any]:
268
+ """
269
+ Delete a user's agent.
270
+
271
+ Only allows deleting user-owned agents, not system agents.
272
+
273
+ Args:
274
+ name: Agent name
275
+ user_id: User identifier
276
+
277
+ Returns:
278
+ Dict with status and message
279
+ """
280
+ from rem.services.postgres import get_postgres_service
281
+
282
+ postgres = get_postgres_service()
283
+ if not postgres:
284
+ raise RuntimeError("Database not available")
285
+
286
+ await postgres.connect()
287
+ try:
288
+ # Only delete user's own agents
289
+ query = """
290
+ DELETE FROM schemas
291
+ WHERE LOWER(name) = LOWER($1)
292
+ AND category = 'agent'
293
+ AND user_id = $2
294
+ RETURNING name
295
+ """
296
+ row = await postgres.fetchrow(query, name, user_id)
297
+
298
+ if row:
299
+ logger.info(f"🗑️ Agent deleted: {name} (user={user_id})")
300
+ return {
301
+ "status": "success",
302
+ "message": f"Agent '{name}' deleted.",
303
+ }
304
+ else:
305
+ return {
306
+ "status": "error",
307
+ "message": f"Agent '{name}' not found or not owned by you.",
308
+ }
309
+ finally:
310
+ await postgres.disconnect()
rem/agentic/context.py CHANGED
@@ -2,11 +2,15 @@
2
2
  Agent execution context and configuration.
3
3
 
4
4
  Design pattern for session context that can be constructed from:
5
+ - FastAPI Request object (preferred - extracts user from JWT via request.state)
5
6
  - HTTP headers (X-User-Id, X-Session-Id, X-Model-Name, X-Is-Eval, etc.)
6
7
  - Direct instantiation for testing/CLI
7
8
 
9
+ User ID Sources (in priority order):
10
+ 1. request.state.user.id - From JWT token validated by auth middleware (SECURE)
11
+ 2. X-User-Id header - Fallback for backwards compatibility (less secure)
12
+
8
13
  Headers Mapping:
9
- X-User-Id → context.user_id
10
14
  X-Tenant-Id → context.tenant_id (default: "default")
11
15
  X-Session-Id → context.session_id
12
16
  X-Agent-Schema → context.agent_schema_uri (default: "rem")
@@ -128,13 +132,87 @@ class AgentContext(BaseModel):
128
132
  logger.debug(f"No user_id from {source}, using None (anonymous/shared data)")
129
133
  return None
130
134
 
135
+ @classmethod
136
+ def from_request(cls, request: "Request") -> "AgentContext":
137
+ """
138
+ Construct AgentContext from a FastAPI Request object.
139
+
140
+ This is the PREFERRED method for API endpoints. It extracts user_id
141
+ from the authenticated user in request.state (set by auth middleware
142
+ from JWT token), which is more secure than trusting X-User-Id header.
143
+
144
+ Priority for user_id:
145
+ 1. request.state.user.id - From validated JWT token (SECURE)
146
+ 2. X-User-Id header - Fallback for backwards compatibility
147
+
148
+ Args:
149
+ request: FastAPI Request object
150
+
151
+ Returns:
152
+ AgentContext with user from JWT and other values from headers
153
+
154
+ Example:
155
+ @app.post("/api/v1/chat/completions")
156
+ async def chat(request: Request, body: ChatRequest):
157
+ context = AgentContext.from_request(request)
158
+ # context.user_id is from JWT, not header
159
+ """
160
+ from typing import TYPE_CHECKING
161
+ if TYPE_CHECKING:
162
+ from starlette.requests import Request
163
+
164
+ # Get headers dict
165
+ headers = dict(request.headers)
166
+ normalized = {k.lower(): v for k, v in headers.items()}
167
+
168
+ # Extract user_id from authenticated user (JWT) - this is the source of truth
169
+ user_id = None
170
+ tenant_id = "default"
171
+
172
+ if hasattr(request, "state"):
173
+ user = getattr(request.state, "user", None)
174
+ if user and isinstance(user, dict):
175
+ user_id = user.get("id")
176
+ # Also get tenant_id from authenticated user if available
177
+ if user.get("tenant_id"):
178
+ tenant_id = user.get("tenant_id")
179
+ if user_id:
180
+ logger.debug(f"User ID from JWT: {user_id}")
181
+
182
+ # Fallback to X-User-Id header if no authenticated user
183
+ if not user_id:
184
+ user_id = normalized.get("x-user-id")
185
+ if user_id:
186
+ logger.debug(f"User ID from X-User-Id header (fallback): {user_id}")
187
+
188
+ # Override tenant_id from header if provided
189
+ header_tenant = normalized.get("x-tenant-id")
190
+ if header_tenant:
191
+ tenant_id = header_tenant
192
+
193
+ # Parse X-Is-Eval header
194
+ is_eval_str = normalized.get("x-is-eval", "").lower()
195
+ is_eval = is_eval_str in ("true", "1", "yes")
196
+
197
+ return cls(
198
+ user_id=user_id,
199
+ tenant_id=tenant_id,
200
+ session_id=normalized.get("x-session-id"),
201
+ default_model=normalized.get("x-model-name") or settings.llm.default_model,
202
+ agent_schema_uri=normalized.get("x-agent-schema"),
203
+ is_eval=is_eval,
204
+ )
205
+
131
206
  @classmethod
132
207
  def from_headers(cls, headers: dict[str, str]) -> "AgentContext":
133
208
  """
134
- Construct AgentContext from HTTP headers.
209
+ Construct AgentContext from HTTP headers dict.
210
+
211
+ NOTE: Prefer from_request() for API endpoints as it extracts user_id
212
+ from the validated JWT token in request.state, which is more secure.
135
213
 
136
214
  Reads standard headers:
137
- - X-User-Id: User identifier
215
+ - X-User-Id: User identifier (fallback - prefer JWT)
138
216
  - X-Tenant-Id: Tenant identifier
139
217
  - X-Session-Id: Session identifier
140
218
  - X-Model-Name: Model override
@@ -103,6 +103,7 @@ class ContextBuilder:
103
103
  headers: dict[str, str],
104
104
  new_messages: list[dict[str, str]] | None = None,
105
105
  db: PostgresService | None = None,
106
+ user_id: str | None = None,
106
107
  ) -> tuple[AgentContext, list[ContextMessage]]:
107
108
  """
108
109
  Build complete context from HTTP headers.
@@ -125,6 +126,7 @@ class ContextBuilder:
125
126
  headers: HTTP request headers (case-insensitive)
126
127
  new_messages: New messages from current request
127
128
  db: Optional PostgresService (creates if None)
129
+ user_id: Override user_id from JWT token (takes precedence over X-User-Id header)
128
130
 
129
131
  Returns:
130
132
  Tuple of (AgentContext, messages list)
@@ -147,6 +149,17 @@ class ContextBuilder:
147
149
  # Extract AgentContext from headers
148
150
  context = AgentContext.from_headers(headers)
149
151
 
152
+ # Override user_id if provided (from JWT token - takes precedence over header)
153
+ if user_id is not None:
154
+ context = AgentContext(
155
+ user_id=user_id,
156
+ tenant_id=context.tenant_id,
157
+ session_id=context.session_id,
158
+ default_model=context.default_model,
159
+ agent_schema_uri=context.agent_schema_uri,
160
+ is_eval=context.is_eval,
161
+ )
162
+
150
163
  # Initialize DB if not provided and needed (for user context or session history)
151
164
  close_db = False
152
165
  if db is None and (settings.chat.auto_inject_user_context or context.session_id):
@@ -184,13 +197,15 @@ class ContextBuilder:
184
197
  # Add system context hint
185
198
  messages.append(ContextMessage(role="system", content=context_hint))
186
199
 
187
- # ALWAYS load session history (if session_id provided) with compression
200
+ # ALWAYS load session history (if session_id provided)
201
+ # - Long assistant messages are compressed on load with REM LOOKUP hints
202
+ # - Tool messages are never compressed (contain structured metadata)
188
203
  if context.session_id and settings.postgres.enabled:
189
204
  store = SessionMessageStore(user_id=context.user_id or "default")
190
205
  session_history = await store.load_session_messages(
191
206
  session_id=context.session_id,
192
207
  user_id=context.user_id,
193
- decompress=False, # Use compressed versions with REM LOOKUP hints
208
+ compress_on_load=True, # Compress long assistant messages
194
209
  )
195
210
 
196
211
  # Convert to ContextMessage format
@@ -202,7 +217,7 @@ class ContextBuilder:
202
217
  )
203
218
  )
204
219
 
205
- logger.debug(f"Loaded {len(session_history)} compressed messages for session {context.session_id}")
220
+ logger.debug(f"Loaded {len(session_history)} messages for session {context.session_id}")
206
221
 
207
222
  # Add new messages from request
208
223
  if new_messages:
rem/api/deps.py CHANGED
@@ -147,7 +147,6 @@ def is_admin(user: dict | None) -> bool:
147
147
  async def get_user_filter(
148
148
  request: Request,
149
149
  x_user_id: str | None = None,
150
- x_tenant_id: str = "default",
151
150
  ) -> dict[str, Any]:
152
151
  """
153
152
  Get user-scoped filter dict for database queries.
@@ -158,7 +157,6 @@ async def get_user_filter(
158
157
  Args:
159
158
  request: FastAPI request
160
159
  x_user_id: Optional user_id filter (admin only for cross-user)
161
- x_tenant_id: Tenant ID for multi-tenancy
162
160
 
163
161
  Returns:
164
162
  Filter dict with appropriate user_id constraint
@@ -169,7 +167,7 @@ async def get_user_filter(
169
167
  return await repo.find(filters)
170
168
  """
171
169
  user = get_current_user(request)
172
- filters: dict[str, Any] = {"tenant_id": x_tenant_id}
170
+ filters: dict[str, Any] = {}
173
171
 
174
172
  if is_admin(user):
175
173
  # Admin can filter by any user or see all
@@ -185,8 +183,8 @@ async def get_user_filter(
185
183
  f"User {user.get('email')} attempted to filter by user_id={x_user_id}"
186
184
  )
187
185
  else:
188
- # Anonymous: could use anonymous tracking ID or restrict access
189
- # For now, anonymous can't access user-scoped data
186
+ # Anonymous: use anonymous tracking ID
187
+ # Note: user_id should come from JWT, not from parameters
190
188
  anon_id = getattr(request.state, "anon_id", None)
191
189
  if anon_id:
192
190
  filters["user_id"] = f"anon:{anon_id}"
rem/api/main.py CHANGED
@@ -149,19 +149,38 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
149
149
  client_host = request.client.host if request.client else "unknown"
150
150
  user_agent = request.headers.get('user-agent', 'unknown')[:100]
151
151
 
152
+ # Extract auth info for logging (first 8 chars of token for debugging)
153
+ auth_header = request.headers.get('authorization', '')
154
+ auth_preview = ""
155
+ if auth_header.startswith('Bearer '):
156
+ token = auth_header[7:]
157
+ auth_preview = f"Bearer {token[:8]}..." if len(token) > 8 else f"Bearer {token}"
158
+
152
159
  # Process request
153
160
  response = await call_next(request)
154
161
 
162
+ # Extract user info set by auth middleware (after processing)
163
+ user = getattr(request.state, "user", None)
164
+ user_id = user.get("id", "none")[:12] if user else "anon"
165
+ user_email = user.get("email", "") if user else ""
166
+
155
167
  # Determine log level based on path AND response status
156
168
  duration_ms = (time.time() - start_time) * 1000
157
169
  use_debug = self._should_log_at_debug(path, response.status_code)
158
170
  log_fn = logger.debug if use_debug else logger.info
159
171
 
160
- # Log request and response together
172
+ # Build user info string
173
+ user_info = f"user={user_id}"
174
+ if user_email:
175
+ user_info += f" ({user_email})"
176
+ if auth_preview:
177
+ user_info += f" | auth={auth_preview}"
178
+
179
+ # Log request and response together with auth info
161
180
  log_fn(
162
181
  f"→ REQUEST: {request.method} {path} | "
163
182
  f"Client: {client_host} | "
164
- f"User-Agent: {user_agent}"
183
+ f"{user_info}"
165
184
  )
166
185
  log_fn(
167
186
  f"← RESPONSE: {request.method} {path} | "
@@ -304,7 +323,7 @@ def create_app() -> FastAPI:
304
323
  app.add_middleware(
305
324
  AuthMiddleware,
306
325
  protected_paths=["/api/v1"],
307
- excluded_paths=["/api/auth", "/api/dev", "/api/v1/mcp/auth"],
326
+ excluded_paths=["/api/auth", "/api/dev", "/api/v1/mcp/auth", "/api/v1/slack"],
308
327
  # Allow anonymous when auth is disabled, otherwise use setting
309
328
  allow_anonymous=(not settings.auth.enabled) or settings.auth.allow_anonymous,
310
329
  # MCP requires auth only when auth is fully enabled
@@ -182,6 +182,7 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
182
182
  list_schema,
183
183
  read_resource,
184
184
  register_metadata,
185
+ save_agent,
185
186
  search_rem,
186
187
  )
187
188
 
@@ -191,6 +192,7 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
191
192
  mcp.tool()(register_metadata)
192
193
  mcp.tool()(list_schema)
193
194
  mcp.tool()(get_schema)
195
+ mcp.tool()(save_agent)
194
196
 
195
197
  # File ingestion tool (with local path support for local servers)
196
198
  # Wrap to inject is_local parameter