footprinter-cli 1.0.0__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 (134) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +444 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/cli/__init__.py +128 -0
  19. footprinter/cli/__main__.py +6 -0
  20. footprinter/cli/_common.py +332 -0
  21. footprinter/cli/_policy_helpers.py +646 -0
  22. footprinter/cli/_prompt.py +220 -0
  23. footprinter/cli/api_cmd.py +32 -0
  24. footprinter/cli/connect.py +591 -0
  25. footprinter/cli/data.py +879 -0
  26. footprinter/cli/delete.py +128 -0
  27. footprinter/cli/ingest.py +579 -0
  28. footprinter/cli/mcp_cmd.py +750 -0
  29. footprinter/cli/mcp_setup.py +306 -0
  30. footprinter/cli/search.py +393 -0
  31. footprinter/cli/search_cmd.py +69 -0
  32. footprinter/cli/setup.py +1836 -0
  33. footprinter/cli/status.py +729 -0
  34. footprinter/cli/status_cmd.py +104 -0
  35. footprinter/cli/upsert.py +794 -0
  36. footprinter/cli/vectorize_cmd.py +215 -0
  37. footprinter/cli/view.py +322 -0
  38. footprinter/connectors/__init__.py +171 -0
  39. footprinter/connectors/config_utils.py +141 -0
  40. footprinter/db/__init__.py +37 -0
  41. footprinter/db/browser.py +198 -0
  42. footprinter/db/chats.py +610 -0
  43. footprinter/db/clients.py +307 -0
  44. footprinter/db/emails.py +279 -0
  45. footprinter/db/files.py +741 -0
  46. footprinter/db/folders.py +659 -0
  47. footprinter/db/messages.py +192 -0
  48. footprinter/db/policies.py +151 -0
  49. footprinter/db/projects.py +673 -0
  50. footprinter/db/search.py +573 -0
  51. footprinter/db/sql_utils.py +168 -0
  52. footprinter/db/status.py +320 -0
  53. footprinter/db/uploads.py +70 -0
  54. footprinter/ingest/__init__.py +0 -0
  55. footprinter/ingest/adapters/__init__.py +33 -0
  56. footprinter/ingest/adapters/browser.py +54 -0
  57. footprinter/ingest/adapters/chat.py +57 -0
  58. footprinter/ingest/adapters/ingest.py +146 -0
  59. footprinter/ingest/adapters/local_files.py +68 -0
  60. footprinter/ingest/adapters/local_folders.py +52 -0
  61. footprinter/ingest/adapters/protocol.py +174 -0
  62. footprinter/ingest/browser_indexer.py +216 -0
  63. footprinter/ingest/chat_dedup.py +156 -0
  64. footprinter/ingest/chat_indexer.py +515 -0
  65. footprinter/ingest/chat_parsers/__init__.py +8 -0
  66. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  67. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  68. footprinter/ingest/cli.py +827 -0
  69. footprinter/ingest/content_extractors.py +117 -0
  70. footprinter/ingest/database.py +36 -0
  71. footprinter/ingest/db/__init__.py +1 -0
  72. footprinter/ingest/db/connector_schema.py +47 -0
  73. footprinter/ingest/db/migration.py +328 -0
  74. footprinter/ingest/db/schema.py +1043 -0
  75. footprinter/ingest/db/security.py +6 -0
  76. footprinter/ingest/file_indexer.py +261 -0
  77. footprinter/ingest/file_scanner.py +277 -0
  78. footprinter/ingest/folder_indexer.py +226 -0
  79. footprinter/ingest/full_content_extractor.py +321 -0
  80. footprinter/ingest/orchestrator.py +125 -0
  81. footprinter/ingest/pipe_runner.py +217 -0
  82. footprinter/ingest/processing.py +165 -0
  83. footprinter/ingest/registry.py +201 -0
  84. footprinter/ingest/run_record.py +91 -0
  85. footprinter/ingest/status.py +346 -0
  86. footprinter/mcp/__init__.py +0 -0
  87. footprinter/mcp/__main__.py +5 -0
  88. footprinter/mcp/db.py +57 -0
  89. footprinter/mcp/errors.py +102 -0
  90. footprinter/mcp/extraction.py +226 -0
  91. footprinter/mcp/server.py +39 -0
  92. footprinter/mcp/tools/__init__.py +0 -0
  93. footprinter/mcp/tools/navigation.py +70 -0
  94. footprinter/mcp/tools/read.py +75 -0
  95. footprinter/mcp/tools/search.py +158 -0
  96. footprinter/mcp/tools/semantic.py +79 -0
  97. footprinter/mcp/tools/status.py +15 -0
  98. footprinter/paths.py +91 -0
  99. footprinter/permissions.py +1160 -0
  100. footprinter/semantic/__init__.py +13 -0
  101. footprinter/semantic/chunking.py +52 -0
  102. footprinter/semantic/embeddings.py +23 -0
  103. footprinter/semantic/hybrid_search.py +273 -0
  104. footprinter/semantic/vector_store.py +471 -0
  105. footprinter/services/__init__.py +49 -0
  106. footprinter/services/access_service.py +342 -0
  107. footprinter/services/chat_service.py +85 -0
  108. footprinter/services/client_service.py +267 -0
  109. footprinter/services/content_service.py +181 -0
  110. footprinter/services/email_service.py +89 -0
  111. footprinter/services/file_service.py +83 -0
  112. footprinter/services/folder_service.py +122 -0
  113. footprinter/services/includes.py +19 -0
  114. footprinter/services/ingest_service.py +231 -0
  115. footprinter/services/project_service.py +262 -0
  116. footprinter/services/roles.py +25 -0
  117. footprinter/services/search_service.py +177 -0
  118. footprinter/services/semantic_service.py +360 -0
  119. footprinter/services/status_service.py +18 -0
  120. footprinter/services/visit_service.py +65 -0
  121. footprinter/source_registry.py +194 -0
  122. footprinter/utils/__init__.py +7 -0
  123. footprinter/utils/hash_utils.py +59 -0
  124. footprinter/utils/logging_config.py +68 -0
  125. footprinter/utils/mime.py +30 -0
  126. footprinter/utils/text.py +6 -0
  127. footprinter/utils/time.py +11 -0
  128. footprinter/visibility.py +1272 -0
  129. footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
  130. footprinter_cli-1.0.0.dist-info/METADATA +229 -0
  131. footprinter_cli-1.0.0.dist-info/RECORD +134 -0
  132. footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
  133. footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
  134. footprinter_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,346 @@
1
+ """Status reporting — terminal status display, stage detail formatting, data counts."""
2
+
3
+ import sqlite3
4
+ from typing import Dict, List
5
+
6
+ from footprinter.paths import get_db_path
7
+
8
+
9
+ def get_status(db_path: str = None) -> Dict:
10
+ """Get status of all data sources.
11
+
12
+ Args:
13
+ db_path: Path to SQLite database. Falls back to get_db_path().
14
+ """
15
+ if db_path is None:
16
+ db_path = str(get_db_path())
17
+
18
+ conn = sqlite3.connect(db_path, timeout=10)
19
+ conn.row_factory = sqlite3.Row
20
+ conn.execute("PRAGMA busy_timeout=5000")
21
+ conn.execute("PRAGMA foreign_keys=ON")
22
+ cursor = conn.cursor()
23
+
24
+ status = {}
25
+
26
+ # Files - status != 'removed' means active files (includes
27
+ # 'active' and 'hidden')
28
+ cursor.execute(
29
+ """
30
+ SELECT source, COUNT(*) as count, SUM(size_bytes) as size
31
+ FROM files WHERE status != 'removed'
32
+ GROUP BY source
33
+ """
34
+ )
35
+ status["files"] = {
36
+ row["source"]: {
37
+ "count": row["count"],
38
+ "size_mb": round((row["size"] or 0) / 1024 / 1024, 1),
39
+ }
40
+ for row in cursor.fetchall()
41
+ }
42
+
43
+ cursor.execute("SELECT COUNT(*) FROM files WHERE status != 'removed'")
44
+ status["files_total"] = cursor.fetchone()[0]
45
+
46
+ # Indexed folders
47
+ cursor.execute(
48
+ """
49
+ SELECT source, COUNT(*) as count
50
+ FROM folders WHERE status != 'removed'
51
+ GROUP BY source
52
+ """
53
+ )
54
+ status["folders"] = {row["source"] or "local": row["count"] for row in cursor.fetchall()}
55
+
56
+ # Browser visits
57
+ cursor.execute("SELECT COUNT(*) FROM visits")
58
+ status["visits"] = cursor.fetchone()[0]
59
+
60
+ # Emails
61
+ cursor.execute("SELECT COUNT(*) FROM emails")
62
+ status["emails"] = cursor.fetchone()[0]
63
+
64
+ # Chats
65
+ cursor.execute("SELECT account, COUNT(*) as count FROM chats GROUP BY account")
66
+ status["chats"] = {row["account"]: row["count"] for row in cursor.fetchall()}
67
+
68
+ cursor.execute("SELECT COUNT(*) FROM messages")
69
+ status["messages"] = cursor.fetchone()[0]
70
+
71
+ # Projects
72
+ cursor.execute("SELECT COUNT(*) FROM projects")
73
+ status["projects"] = cursor.fetchone()[0]
74
+
75
+ # retention classifications removed — not part of CLI tool
76
+
77
+ # Access resolution — count entities with stamped visibility
78
+ access = {}
79
+ for table in ("files", "emails", "chats"):
80
+ try:
81
+ where = "mcp_view IS NOT NULL"
82
+ if table == "files":
83
+ where += " AND status != 'removed'"
84
+ stamped = cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}").fetchone()[0]
85
+ total_where = "status != 'removed'" if table == "files" else "1=1"
86
+ total = cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE {total_where}").fetchone()[0]
87
+ access[table] = {"stamped": stamped, "total": total}
88
+ except sqlite3.OperationalError:
89
+ pass
90
+ status["access_resolution"] = access
91
+
92
+ # FTS health — check existence and integrity inline on the existing
93
+ # connection to avoid opening a second Database() instance
94
+ fts_tables = {
95
+ "files_fts": "files",
96
+ "emails_fts": "emails",
97
+ "chats_fts": "chats",
98
+ }
99
+ fts = {}
100
+ for fts_table, base_table in fts_tables.items():
101
+ base_rows = cursor.execute(f"SELECT COUNT(*) FROM {base_table}").fetchone()[0]
102
+ try:
103
+ fts_rows = cursor.execute(f"SELECT COUNT(*) FROM {fts_table}").fetchone()[0]
104
+ except sqlite3.OperationalError:
105
+ fts[fts_table] = {
106
+ "status": "error",
107
+ "fts_rows": None,
108
+ "base_rows": base_rows,
109
+ }
110
+ continue
111
+ # FTS5 integrity-check detects index drift
112
+ try:
113
+ cursor.execute(f"INSERT INTO {fts_table}({fts_table}, rank) VALUES('integrity-check', 1)")
114
+ fts[fts_table] = {
115
+ "status": "ok",
116
+ "fts_rows": fts_rows,
117
+ "base_rows": base_rows,
118
+ }
119
+ except sqlite3.DatabaseError:
120
+ fts[fts_table] = {
121
+ "status": "drift",
122
+ "fts_rows": fts_rows,
123
+ "base_rows": base_rows,
124
+ }
125
+ status["fts"] = fts
126
+
127
+ conn.close()
128
+
129
+ return status
130
+
131
+
132
+ def _stage_detail_string(result: Dict) -> str:
133
+ """Extract a short detail string from a stage result dict."""
134
+ reason = result.get("reason")
135
+ if reason:
136
+ return str(reason)
137
+
138
+ known_keys = {
139
+ "files_indexed": "files",
140
+ "folders_found": "folders",
141
+ "folders_indexed": "folders",
142
+ "urls_indexed": "urls",
143
+ "emails_indexed": "emails",
144
+ "inserted": "inserted",
145
+ "updated": "updated",
146
+ "skipped": "skipped",
147
+ "errors": "errors",
148
+ }
149
+ parts = []
150
+ for key, label in known_keys.items():
151
+ if key in result and isinstance(result[key], (int, float)):
152
+ parts.append(f"{result[key]:,} {label}")
153
+
154
+ # Check nested dicts with 'status' key (sub-results like classification, scoring)
155
+ for key, value in result.items():
156
+ if key in (
157
+ "stage",
158
+ "status",
159
+ "elapsed_seconds",
160
+ "error",
161
+ "error_type",
162
+ "recoverable",
163
+ "mode",
164
+ "note",
165
+ ):
166
+ continue
167
+ if isinstance(value, dict) and "status" in value:
168
+ sub_status = value["status"]
169
+ if sub_status == "error":
170
+ parts.append(f"{key}: error")
171
+ else:
172
+ # Pull a useful number from the sub-result
173
+ for sub_key in (
174
+ "processed",
175
+ "files_processed",
176
+ "messages_indexed",
177
+ "projects_found",
178
+ "files_updated",
179
+ "folders_updated",
180
+ ):
181
+ if sub_key in value and isinstance(value[sub_key], (int, float)):
182
+ parts.append(f"{value[sub_key]:,} {sub_key.replace('_', ' ')}")
183
+ break
184
+
185
+ return ", ".join(parts[:3])
186
+
187
+
188
+ def print_status(status: Dict, quiet: bool = False, console=None):
189
+ """Pretty print status as a Rich table."""
190
+ if quiet:
191
+ return
192
+
193
+ from rich.console import Console
194
+ from rich.table import Table
195
+
196
+ if console is None:
197
+ console = Console()
198
+
199
+ console.print()
200
+ console.print("[bold]Data Pipeline Status[/bold]")
201
+ console.print()
202
+
203
+ # Main data table
204
+ table = Table(show_header=True, header_style="bold")
205
+ table.add_column("Source", style="cyan")
206
+ table.add_column("Count", justify="right")
207
+ table.add_column("Size", justify="right")
208
+
209
+ # File rows by source
210
+ total_count = 0
211
+ total_size = 0.0
212
+ for source, data in status.get("files", {}).items():
213
+ count = data["count"]
214
+ size_mb = data["size_mb"]
215
+ total_count += count
216
+ total_size += size_mb
217
+ table.add_row(f" {source}", f"{count:,}", f"{size_mb:.1f} MB")
218
+
219
+ # Non-file sources
220
+ browser = status.get("visits", 0)
221
+ emails = status.get("emails", 0)
222
+ messages_count = status.get("messages", 0)
223
+ projects = status.get("projects", 0)
224
+
225
+ table.add_row("Browser history", f"{browser:,}", "")
226
+ table.add_row("Emails", f"{emails:,}", "")
227
+ table.add_row("Chat messages", f"{messages_count:,}", "")
228
+ table.add_row("Projects", f"{projects:,}", "")
229
+
230
+ # Chats and folders as table rows
231
+ chat_total = sum(status.get("chats", {}).values())
232
+ if chat_total:
233
+ table.add_row("Chats", f"{chat_total:,}", "")
234
+ folder_total = sum(status.get("folders", {}).values())
235
+ if folder_total:
236
+ table.add_row("Indexed folders", f"{folder_total:,}", "")
237
+
238
+ # Total row
239
+ table.add_section()
240
+ table.add_row(
241
+ "[bold]Total files[/bold]",
242
+ f"[bold]{total_count:,}[/bold]",
243
+ f"[bold]{total_size:.1f} MB[/bold]",
244
+ )
245
+
246
+ console.print(table)
247
+
248
+ # FTS health section
249
+ fts_data = status.get("fts", {})
250
+ if fts_data:
251
+ console.print()
252
+ console.print("[bold]FTS Search Indexes[/bold]")
253
+ fts_table = Table(show_header=True, header_style="bold")
254
+ fts_table.add_column("Index", style="cyan")
255
+ fts_table.add_column("Rows", justify="right")
256
+ fts_table.add_column("Status")
257
+
258
+ for idx_name, info in fts_data.items():
259
+ idx_status = info.get("status", "unknown")
260
+ if idx_status == "ok":
261
+ status_text = "[green]ok[/green]"
262
+ rows_text = f"{info['base_rows']:,}"
263
+ elif idx_status == "error":
264
+ status_text = "[red]missing[/red]"
265
+ rows_text = "—"
266
+ else:
267
+ status_text = f"[yellow]{idx_status}[/yellow]"
268
+ rows_text = f"{info.get('fts_rows', '?')}"
269
+ fts_table.add_row(idx_name, rows_text, status_text)
270
+
271
+ console.print(fts_table)
272
+
273
+ console.print()
274
+
275
+
276
+ def _print_completion_summary(console, results: List[Dict], *, show_next_steps: bool = True):
277
+ """Print a completion summary after pipeline run."""
278
+ total_time = sum(r.get("elapsed_seconds", 0) for r in results)
279
+ error_count = sum(1 for r in results if r.get("status") == "error")
280
+ warn_count = sum(1 for r in results if r.get("status") == "completed_with_errors")
281
+ completed_count = sum(1 for r in results if r.get("status") in ("completed", "completed_with_errors", "info"))
282
+
283
+ console.print()
284
+ if error_count == 0 and warn_count == 0:
285
+ console.print(f"[bold green]Pipeline complete[/bold green] {completed_count} stages in {total_time:.1f}s")
286
+ elif error_count == 0:
287
+ console.print(
288
+ f"[bold yellow]Pipeline complete with {warn_count} warning(s)[/bold yellow] "
289
+ f"{completed_count} stages in {total_time:.1f}s"
290
+ )
291
+ else:
292
+ console.print(
293
+ f"[bold yellow]Pipeline finished with {error_count} error(s)[/bold yellow] "
294
+ f"{completed_count} OK, {error_count} failed in {total_time:.1f}s"
295
+ )
296
+
297
+ if show_next_steps:
298
+ console.print()
299
+ console.print("[dim]Next steps:[/dim]")
300
+ console.print("[dim] fp mcp Configure Claude Desktop[/dim]")
301
+ console.print("[dim] fp status Show data counts[/dim]")
302
+ console.print()
303
+
304
+
305
+ def print_results(results: List[Dict], quiet: bool = False, console=None, *, show_next_steps: bool = True):
306
+ """Pretty print pipeline results as a Rich table."""
307
+ if quiet:
308
+ return
309
+
310
+ from rich.console import Console
311
+ from rich.table import Table
312
+
313
+ if console is None:
314
+ console = Console()
315
+
316
+ console.print()
317
+ table = Table(show_header=True, header_style="bold", title="Pipeline Results")
318
+ table.add_column("Stage", style="cyan")
319
+ table.add_column("Status")
320
+ table.add_column("Time", justify="right")
321
+ table.add_column("Details", style="dim")
322
+
323
+ status_styles = {
324
+ "completed": "[green]OK[/green]",
325
+ "completed_with_errors": "[yellow]WARN[/yellow]",
326
+ "info": "[blue]info[/blue]",
327
+ "skipped": "[yellow]skip[/yellow]",
328
+ "error": "[red]FAIL[/red]",
329
+ }
330
+
331
+ for result in results:
332
+ stage = result.get("stage", "unknown")
333
+ status = result.get("status", "unknown")
334
+ elapsed = result.get("elapsed_seconds", 0)
335
+ status_text = status_styles.get(status, f"[dim]{status}[/dim]")
336
+ details = _stage_detail_string(result)
337
+
338
+ if status == "error":
339
+ error_msg = result.get("error", "")
340
+ if error_msg:
341
+ details = str(error_msg)[:200]
342
+
343
+ table.add_row(stage, status_text, f"{elapsed:.1f}s", details)
344
+
345
+ console.print(table)
346
+ _print_completion_summary(console, results, show_next_steps=show_next_steps)
File without changes
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m footprinter.mcp"""
2
+
3
+ from footprinter.mcp.server import main
4
+
5
+ main()
footprinter/mcp/db.py ADDED
@@ -0,0 +1,57 @@
1
+ """Database connection for Footprinter MCP server."""
2
+
3
+ import functools
4
+ import sqlite3
5
+ from contextlib import contextmanager
6
+
7
+ from footprinter.mcp.errors import mcp_error
8
+ from footprinter.paths import get_db_path
9
+ from footprinter.services.access_service import load_globals
10
+
11
+
12
+ class DatabaseNotInitializedError(Exception):
13
+ """Raised when the database exists but has no tables (uninitialized)."""
14
+
15
+
16
+ def _check_db_initialized(conn: sqlite3.Connection) -> None:
17
+ """Check that the database has been initialized with the expected schema.
18
+
19
+ Uses the ``files`` table as a sentinel — if it's missing, the database
20
+ has never been populated by ``fp ingest``.
21
+ """
22
+ row = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'").fetchone()
23
+ if row[0] == 0:
24
+ raise DatabaseNotInitializedError()
25
+
26
+
27
+ @contextmanager
28
+ def get_db():
29
+ """Context manager for read-only database connections.
30
+
31
+ Also calls ``load_globals()`` to refresh the global visibility/permission
32
+ policy cache in ``access_service`` for the current request.
33
+ """
34
+ conn = sqlite3.connect(str(get_db_path()), timeout=10)
35
+ conn.row_factory = sqlite3.Row
36
+ conn.execute("PRAGMA busy_timeout=5000")
37
+ conn.execute("PRAGMA foreign_keys=ON")
38
+ conn.execute("PRAGMA query_only = ON")
39
+ try:
40
+ _check_db_initialized(conn)
41
+ load_globals(conn)
42
+ yield conn
43
+ finally:
44
+ conn.close()
45
+
46
+
47
+ def handle_db_errors(func):
48
+ """Decorator that catches DatabaseNotInitializedError and returns a structured MCP error."""
49
+
50
+ @functools.wraps(func)
51
+ def wrapper(*args, **kwargs):
52
+ try:
53
+ return func(*args, **kwargs)
54
+ except DatabaseNotInitializedError:
55
+ return mcp_error("DB_NOT_INITIALIZED")
56
+
57
+ return wrapper
@@ -0,0 +1,102 @@
1
+ """Standardized MCP error responses.
2
+
3
+ Provides consistent error handling that:
4
+ - Logs detailed info internally (paths, IDs, exception details)
5
+ - Returns generic messages externally (closes information oracles)
6
+ - Enforces consistent response structure
7
+ """
8
+
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Error codes mapped to user-facing messages (intentionally vague)
14
+ ERROR_MESSAGES = {
15
+ "NOT_FOUND": "Nothing here",
16
+ "VISIBILITY_RESTRICTED": "Veiled",
17
+ "PERMISSION_DENIED": "Forbidden",
18
+ "INVALID_TYPE": "Unknown kind",
19
+ "INVALID_INPUT": "Unclear",
20
+ "MISSING_REQUIRED": "Incomplete",
21
+ "READ_FAILED": "Illegible",
22
+ "EXTRACTION_FAILED": "Resists extraction",
23
+ "DECODE_FAILED": "Indecipherable",
24
+ "CONFIG_ERROR": "Unconfigured",
25
+ "DEPENDENCY_MISSING": "Unequipped",
26
+ "DATABASE_ERROR": "Unreachable",
27
+ "DB_NOT_INITIALIZED": "Unpopulated",
28
+ "SEARCH_FAILED": "Fruitless",
29
+ "QUERY_INVALID": "Too brief",
30
+ }
31
+
32
+ # Agent-friendly hints paired with each error code.
33
+ # Personality stays in ERROR_MESSAGES; these give actionable next steps.
34
+ ERROR_HINTS = {
35
+ "NOT_FOUND": "Check the ID or type and retry",
36
+ "VISIBILITY_RESTRICTED": "This item's metadata is included — content requires a visibility change",
37
+ "PERMISSION_DENIED": "Access policy blocks this item — request a permission change",
38
+ "INVALID_TYPE": "Check the type parameter — see tool description for valid values",
39
+ "INVALID_INPUT": "Review the required parameters and retry",
40
+ "MISSING_REQUIRED": "A required parameter is missing — check the tool schema",
41
+ "READ_FAILED": "The item exists but could not be read — retry or try a different format",
42
+ "EXTRACTION_FAILED": "Text extraction failed — retry with format='raw'",
43
+ "DECODE_FAILED": "Content is not valid text — this may be a binary item",
44
+ "CONFIG_ERROR": "A required service is not configured — check setup",
45
+ "DEPENDENCY_MISSING": "A required dependency is not installed",
46
+ "DATABASE_ERROR": "Storage is temporarily unavailable — retry shortly",
47
+ "DB_NOT_INITIALIZED": "No data indexed yet — run 'fp ingest' to populate",
48
+ "SEARCH_FAILED": "Search could not complete — try a different query or retry",
49
+ "QUERY_INVALID": "Query is too short — provide at least a few words",
50
+ }
51
+
52
+
53
+ def mcp_error(
54
+ code: str,
55
+ *,
56
+ detail: str = None,
57
+ metadata: dict = None,
58
+ hint: str = None,
59
+ internal_message: str = None,
60
+ level: str = "warning",
61
+ ) -> dict:
62
+ """Create a standardized MCP error response.
63
+
64
+ Args:
65
+ code: Error code from ERROR_MESSAGES (e.g., "NOT_FOUND", "INVALID_TYPE")
66
+ detail: Override default message (use sparingly - may leak info)
67
+ metadata: Pre-filtered metadata to include in response
68
+ hint: Override default hint (actionable guidance for agents)
69
+ internal_message: Logged only, never exposed to client
70
+ level: Logging level ("debug", "info", "warning", "error")
71
+
72
+ Returns:
73
+ Dict with 'error', 'error_code', and optionally 'metadata' and 'hint'
74
+
75
+ Example:
76
+ >>> mcp_error("NOT_FOUND", internal_message=f"file {id} missing")
77
+ {"error": "Nothing here", "error_code": "NOT_FOUND",
78
+ "hint": "Check the ID or type and retry"}
79
+
80
+ >>> mcp_error("NOT_FOUND", hint="Try searching by name instead")
81
+ {"error": "Nothing here", "error_code": "NOT_FOUND",
82
+ "hint": "Try searching by name instead"}
83
+ """
84
+ # Get user-facing message (or use detail override)
85
+ message = detail if detail else ERROR_MESSAGES.get(code, "Error")
86
+
87
+ # Log internal details (never exposed)
88
+ if internal_message:
89
+ log_func = getattr(logger, level, logger.warning)
90
+ log_func(f"[{code}] {internal_message}")
91
+
92
+ # Build response
93
+ result = {"error": message, "error_code": code}
94
+ if metadata:
95
+ result["metadata"] = metadata
96
+
97
+ # Resolve hint: explicit override > default lookup > omit
98
+ resolved_hint = hint if hint else ERROR_HINTS.get(code)
99
+ if resolved_hint:
100
+ result["hint"] = resolved_hint
101
+
102
+ return result