agentgraph-server 0.5.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 (49) hide show
  1. agentgraph/__init__.py +1 -0
  2. agentgraph/auth/__init__.py +0 -0
  3. agentgraph/auth/credentials.py +224 -0
  4. agentgraph/backends/__init__.py +50 -0
  5. agentgraph/backends/sqlite/__init__.py +1 -0
  6. agentgraph/backends/sqlite/backend.py +1471 -0
  7. agentgraph/backends/sqlite/vector.py +142 -0
  8. agentgraph/cli.py +721 -0
  9. agentgraph/cli_query.py +519 -0
  10. agentgraph/config.py +90 -0
  11. agentgraph/connectors/__init__.py +0 -0
  12. agentgraph/connectors/base.py +455 -0
  13. agentgraph/connectors/registry.py +78 -0
  14. agentgraph/connectors/status.py +244 -0
  15. agentgraph/core/__init__.py +0 -0
  16. agentgraph/core/context.py +26 -0
  17. agentgraph/core/runtime.py +36 -0
  18. agentgraph/core/storage.py +240 -0
  19. agentgraph/graph/__init__.py +1 -0
  20. agentgraph/graph/bookmark.py +87 -0
  21. agentgraph/graph/delete.py +17 -0
  22. agentgraph/graph/download.py +35 -0
  23. agentgraph/graph/embeddings.py +58 -0
  24. agentgraph/graph/fetch.py +53 -0
  25. agentgraph/graph/gc.py +26 -0
  26. agentgraph/graph/link.py +63 -0
  27. agentgraph/graph/person.py +40 -0
  28. agentgraph/graph/query.py +244 -0
  29. agentgraph/graph/upsert.py +49 -0
  30. agentgraph/logging.py +78 -0
  31. agentgraph/mcp/__init__.py +0 -0
  32. agentgraph/mcp/server.py +811 -0
  33. agentgraph/perf.py +43 -0
  34. agentgraph/server/__init__.py +0 -0
  35. agentgraph/server/app.py +133 -0
  36. agentgraph/server/cli_api.py +708 -0
  37. agentgraph/server/dwell.py +79 -0
  38. agentgraph/server/graph_api.py +46 -0
  39. agentgraph/server/router.py +47 -0
  40. agentgraph/server/sync.py +247 -0
  41. agentgraph/skills.py +93 -0
  42. agentgraph_server-0.5.0.data/data/.agents/skills/graph/SKILL.md +159 -0
  43. agentgraph_server-0.5.0.data/data/.agents/skills/slack-auth/SKILL.md +92 -0
  44. agentgraph_server-0.5.0.dist-info/METADATA +286 -0
  45. agentgraph_server-0.5.0.dist-info/RECORD +49 -0
  46. agentgraph_server-0.5.0.dist-info/WHEEL +5 -0
  47. agentgraph_server-0.5.0.dist-info/entry_points.txt +2 -0
  48. agentgraph_server-0.5.0.dist-info/licenses/LICENSE +21 -0
  49. agentgraph_server-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,811 @@
1
+ """AgentGraph MCP server.
2
+
3
+ Exposes the knowledge graph as MCP tools so AI agents can search,
4
+ retrieve, and traverse entities directly.
5
+
6
+ Run via:
7
+ agentgraph mcp
8
+ or via the `agentgraph/mcp` stdio transport understood by Claude Desktop
9
+ and other MCP clients.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ from typing import Any
18
+
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ from agentgraph.core.context import get_backend
22
+ from agentgraph.graph.query import (
23
+ get_edges,
24
+ get_entity,
25
+ query_by_filter,
26
+ search_entities,
27
+ traverse_graph,
28
+ )
29
+ from agentgraph.perf import timed
30
+
31
+ logger = logging.getLogger(__name__)
32
+ mcp = FastMCP("AgentGraph")
33
+
34
+
35
+ def _truncate_content(entity: dict[str, Any], limit: int = 500) -> None:
36
+ content = entity.get("content")
37
+ if isinstance(content, str) and len(content) > limit:
38
+ entity["content"] = content[:limit] + "…"
39
+ entity["content_truncated"] = True
40
+ else:
41
+ entity["content_truncated"] = False
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # list_connectors — connector discovery for agents
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @mcp.tool()
50
+ async def list_connectors_tool(verify: bool = False) -> str:
51
+ """
52
+ List all installed connectors and their capabilities.
53
+
54
+ Call this first to understand which data sources are available and
55
+ which platform values are valid for the platform= parameter in other
56
+ tools (search_entities_tool, query_by_filter_tool, fetch_entity_tool).
57
+
58
+ Returns:
59
+ JSON array of connector objects, each with:
60
+ - source: platform name to pass as the platform= argument
61
+ - description: what this connector ingests
62
+ - auth_provider: shared auth provider key (e.g. "google"), or null
63
+ for connectors that do not use credentials
64
+ - auth_status: "ok" | "missing" | "invalid", or null when no auth is used
65
+ - auth_detail: aggregate auth summary or error message; null if missing
66
+ - auth_verified: true when credentials were live-checked with provider APIs
67
+ - shared_auth: true when multiple connectors share the same auth provider
68
+ - account_count: number of authenticated accounts for that provider
69
+ - url_patterns: URL patterns this connector recognises
70
+ - polls: true if this connector has its own background poll
71
+ - poll_interval_seconds: direct poll interval, or null
72
+ - poll_delegates: connector sources refreshed by this connector's poll
73
+ - polled_by: connector sources whose poll refreshes this connector
74
+ - sync: human-readable sync summary
75
+ - last_synced_at: latest entity sync timestamp for this connector source, or null
76
+ - last_sync: human-readable last-sync label
77
+ """
78
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors
79
+ from agentgraph.connectors.status import connector_status_items
80
+
81
+ bootstrap()
82
+ all_connectors = get_all_connectors()
83
+ result = await connector_status_items(all_connectors, get_backend(), verify=verify)
84
+ return json.dumps(result)
85
+
86
+
87
+ @mcp.tool()
88
+ async def list_auth_providers_tool(verify: bool = False) -> str:
89
+ """
90
+ List credential-backed authentication providers and their current account/auth state.
91
+
92
+ Connectors that require only configuration, or no setup at all, are omitted.
93
+ Use list_connectors_tool to inspect all installed connectors.
94
+
95
+ Returns:
96
+ JSON array of auth provider objects, each with:
97
+ - provider: auth provider key such as "google" or "slack"
98
+ - description: provider summary
99
+ - connectors: connector sources that use this provider
100
+ - shared: true when multiple connectors use the same provider
101
+ - auth_status: "ok" | "missing" | "invalid"
102
+ - auth_detail: aggregate auth summary or error message; null if missing
103
+ - auth_verified: true when credentials were live-checked with provider APIs
104
+ - accounts: authenticated account rows with account_id, label, workspace_id,
105
+ email, auth_method, auth_status, and auth_detail
106
+ """
107
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors
108
+ from agentgraph.connectors.status import auth_provider_status_items
109
+
110
+ bootstrap()
111
+ result = await auth_provider_status_items(get_all_connectors(), verify=verify)
112
+ return json.dumps(result)
113
+
114
+
115
+ @mcp.tool()
116
+ async def remove_auth_provider_tool(provider: str, account_id: str | None = None) -> str:
117
+ """
118
+ Remove stored credentials for an authentication provider.
119
+
120
+ This is the MCP equivalent of:
121
+ agentgraph auth remove <provider> [--account <account-id>]
122
+
123
+ Removing credentials stops authenticated connector operations such as
124
+ background polling, but does not delete already indexed graph data.
125
+
126
+ Args:
127
+ provider: Auth provider key such as "google", "slack", or "discord".
128
+ account_id: Optional account ID. When omitted, all credentials for the
129
+ provider are removed.
130
+
131
+ Returns:
132
+ JSON object with provider, removed, and account_id when supplied.
133
+ """
134
+ from agentgraph.auth.credentials import remove_platform, remove_platform_account
135
+
136
+ removed = (
137
+ remove_platform_account(provider, account_id)
138
+ if account_id is not None
139
+ else remove_platform(provider)
140
+ )
141
+ result: dict[str, object] = {
142
+ "provider": provider,
143
+ "removed": removed,
144
+ }
145
+ if account_id is not None:
146
+ result["account_id"] = account_id
147
+ return json.dumps(result)
148
+
149
+
150
+ @mcp.tool()
151
+ async def authenticate_provider_tool(
152
+ provider: str,
153
+ args: list[str] | None = None,
154
+ account_id: str | None = None,
155
+ add: bool = False,
156
+ ) -> str:
157
+ """Authenticate a credential-backed provider through its connector-owned flow.
158
+
159
+ This is the MCP equivalent of:
160
+ agentgraph auth <provider> [--account <account-id>] [--add] [provider options]
161
+
162
+ Slack accepts ``--method``, ``--client-id``, ``--xoxc-token``, and
163
+ ``--d-cookie`` in ``args``. A Client ID implies OAuth; use the CLI for guided
164
+ app setup.
165
+ """
166
+ import contextlib
167
+ import io
168
+
169
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors
170
+ from agentgraph.connectors.status import run_auth_provider_flow
171
+
172
+ def _run() -> str:
173
+ output = io.StringIO()
174
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
175
+ run_auth_provider_flow(
176
+ get_all_connectors(),
177
+ provider,
178
+ account_id=account_id,
179
+ add=add,
180
+ args=args,
181
+ )
182
+ return output.getvalue()
183
+
184
+ bootstrap()
185
+ try:
186
+ output = await asyncio.to_thread(_run)
187
+ except ValueError as exc:
188
+ return json.dumps({"provider": provider, "authenticated": False, "error": str(exc)})
189
+ result: dict[str, object] = {"provider": provider, "authenticated": True}
190
+ if output:
191
+ result["output"] = output
192
+ return json.dumps(result)
193
+
194
+
195
+ @mcp.tool()
196
+ async def run_connector_command_tool(source: str, args: list[str]) -> str:
197
+ """
198
+ Run a connector-owned command.
199
+
200
+ This is the MCP equivalent of:
201
+ agentgraph connector <source> <args...>
202
+
203
+ Core dispatches to the connector generically; the connector owns command
204
+ names, argument parsing, and behaviour.
205
+
206
+ Args:
207
+ source: Connector source, e.g. "rss".
208
+ args: Connector command and arguments, e.g.
209
+ ["add", "https://simonwillison.net/atom/everything/"].
210
+ RSS add validates the supplied URLs as feeds before saving and
211
+ queues an RSS poll after a successful change.
212
+ RSS remove is available as:
213
+ ["remove", "https://simonwillison.net/atom/everything/"].
214
+ RSS OPML import is also available as:
215
+ ["import-opml", "/path/to/feeds.opml", "--all"] or
216
+ ["import-opml", "/path/to/feeds.opml", "--select", "1,3-5"].
217
+ Connector-owned help is available as ["--help"].
218
+
219
+ Returns:
220
+ JSON object returned by the connector, or an error.
221
+ """
222
+ from agentgraph.connectors.registry import bootstrap, get_connector
223
+
224
+ bootstrap()
225
+ connector = get_connector(source)
226
+ if connector is None:
227
+ return json.dumps({"error": f"Unknown connector {source!r}"})
228
+ if args in (["--help"], ["help"]):
229
+ return json.dumps({"source": source, "help": type(connector).cli_help()})
230
+ try:
231
+ result = type(connector).run_cli_command(args)
232
+ effects = type(connector).command_effects(args, result)
233
+ if effects.poll:
234
+ from agentgraph.server.sync import schedule_poll_connector
235
+
236
+ result["poll"] = await schedule_poll_connector(connector)
237
+ return json.dumps(result, default=str)
238
+ except (NotImplementedError, OSError, ValueError) as exc:
239
+ return json.dumps({"error": str(exc)})
240
+
241
+
242
+ @mcp.tool()
243
+ async def install_skill_tool(
244
+ skill: str = "graph", target: str = "user", force: bool = False
245
+ ) -> str:
246
+ """
247
+ Install a bundled AgentGraph skill.
248
+
249
+ This is the MCP equivalent of:
250
+ agentgraph install-skill <skill> --target <user|project> [--force]
251
+
252
+ Args:
253
+ skill: Bundled skill name. Defaults to "graph".
254
+ target: "user" installs to ~/.agents/skills. "project" installs to
255
+ ./.agents/skills relative to the MCP server process.
256
+ force: Overwrite an existing installed skill.
257
+
258
+ Returns:
259
+ JSON object with skill, target, source, destination, and overwritten,
260
+ or an error.
261
+ """
262
+ from agentgraph.skills import SkillInstallError, install_skill
263
+
264
+ if target not in ("user", "project"):
265
+ return json.dumps({"error": "Target must be 'user' or 'project'"})
266
+
267
+ try:
268
+ result = install_skill(skill, target=target, force=force)
269
+ return json.dumps(result.to_dict())
270
+ except SkillInstallError as exc:
271
+ return json.dumps({"error": str(exc)})
272
+
273
+
274
+ async def _enrich_results(results: list[dict[str, Any]]) -> None:
275
+ """Let each owning connector apply result presentation fixes in-place."""
276
+ from agentgraph.connectors.registry import bootstrap, get_connector
277
+
278
+ bootstrap()
279
+ by_platform: dict[str, list[dict[str, Any]]] = {}
280
+ for entity in results:
281
+ platform = entity.get("platform")
282
+ if isinstance(platform, str):
283
+ by_platform.setdefault(platform, []).append(entity)
284
+
285
+ async def _enrich_one(platform: str, entities: list[dict[str, Any]]) -> None:
286
+ connector = get_connector(platform)
287
+ if connector is None:
288
+ return
289
+ try:
290
+ with timed("mcp.enrich_results", platform=platform, count=len(entities)):
291
+ await connector.enrich_results(entities)
292
+ except Exception:
293
+ logger.exception("Connector %s failed to enrich MCP results", platform)
294
+
295
+ if by_platform:
296
+ await asyncio.gather(
297
+ *(_enrich_one(platform, entities) for platform, entities in by_platform.items())
298
+ )
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # search_entities — hybrid vector + full-text, RRF fused
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ @mcp.tool()
307
+ async def search_entities_tool(
308
+ query: str,
309
+ entity_types: list[str] | None = None,
310
+ platform: str | None = None,
311
+ limit: int = 10,
312
+ min_score: float = 0.03,
313
+ refresh: bool = False,
314
+ ) -> str:
315
+ """
316
+ Search the knowledge graph using a natural-language query.
317
+
318
+ Combines semantic vector similarity and full-text search via
319
+ Reciprocal Rank Fusion for high-quality results.
320
+
321
+ IMPORTANT — attachments: chat photos, images, and uploaded files are
322
+ stored as attachments on Message entities (in metadata.attachments).
323
+ Gmail email attachments are represented as Gmail Document stubs referenced
324
+ by the owning Thread and can be downloaded with download_entity_tool.
325
+ If the user asks about chat uploads, search Message entities or use
326
+ query_by_filter_tool with has_attachments=True. If the user asks about
327
+ Gmail attachments, inspect the Thread's referenced Document stubs.
328
+
329
+ Args:
330
+ query: Natural-language search query.
331
+ entity_types: Optional list of entity types to restrict results
332
+ (e.g. ["Message", "Document", "Channel"]). To find chat images or
333
+ attachments, pass ["Message"]. To find Gmail attachment stubs, pass
334
+ ["Document"] and platform="gmail".
335
+ platform: Optional platform name to scope the search to a single
336
+ source (e.g. "slack", "discord", "gdocs", "gmail", "rss"). When
337
+ omitted, all platforms are searched. Use this to avoid
338
+ cross-platform noise when the user specifies a source.
339
+ limit: Maximum number of results to return (default 10).
340
+ min_score: Minimum relevance score threshold (0–1, default 0.02).
341
+ Results below this score are suppressed as noise.
342
+ refresh: If true, let connectors refresh or enrich connector-owned
343
+ presentation metadata before returning. Defaults to false to keep
344
+ search responsive.
345
+
346
+ Returns:
347
+ JSON array of matching entities with id, title, content snippet,
348
+ platform, and relevance score. Connectors may refresh or enrich
349
+ connector-owned metadata before results are returned.
350
+ """
351
+ results = await search_entities(
352
+ query, entity_types=entity_types, limit=limit, min_score=min_score, platform=platform
353
+ )
354
+ for r in results:
355
+ _truncate_content(r)
356
+ if refresh:
357
+ await _enrich_results(results)
358
+ return json.dumps(results, default=str)
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # get_entity — full entity by UUID
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ @mcp.tool()
367
+ async def get_entity_tool(entity_id: str) -> str:
368
+ """
369
+ Retrieve full details for a single existing entity.
370
+
371
+ Args:
372
+ entity_id: Entity UUID, UUID prefix, platform ref, or HTTP(S) URL.
373
+
374
+ Returns:
375
+ JSON object with all entity fields, or an error message if not found.
376
+ """
377
+ from agentgraph.graph.query import get_entity_by_url, is_http_url
378
+
379
+ entity = (
380
+ await get_entity_by_url(entity_id)
381
+ if is_http_url(entity_id)
382
+ else await get_entity(entity_id)
383
+ )
384
+ if entity is None:
385
+ return json.dumps({"error": f"Entity {entity_id!r} not found"})
386
+ return json.dumps(entity, default=str)
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # get_edges — edges connected to an entity
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ @mcp.tool()
395
+ async def get_edges_tool(
396
+ entity_id: str,
397
+ edge_type: str | None = None,
398
+ direction: str = "both",
399
+ ) -> str:
400
+ """
401
+ List edges connected to an entity.
402
+
403
+ Args:
404
+ entity_id: UUID of the entity.
405
+ edge_type: Optional edge type filter (e.g. "authored", "posted_in",
406
+ "replied_to", "mentions", "collaborated").
407
+ direction: "in" (incoming), "out" (outgoing), or "both" (default).
408
+
409
+ Returns:
410
+ JSON array of edge objects including source/target references.
411
+ """
412
+ edges = await get_edges(entity_id, edge_type=edge_type, direction=direction)
413
+ return json.dumps(edges, default=str)
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # traverse_graph — BFS neighbourhood
418
+ # ---------------------------------------------------------------------------
419
+
420
+
421
+ @mcp.tool()
422
+ async def traverse_graph_tool(
423
+ entity_id: str,
424
+ max_depth: int = 2,
425
+ ) -> str:
426
+ """
427
+ Traverse the knowledge graph from a starting entity using BFS.
428
+
429
+ Useful for discovering the context around a message or document:
430
+ who authored it, which channel it appeared in, what it references, etc.
431
+
432
+ Args:
433
+ entity_id: UUID of the starting entity.
434
+ max_depth: Maximum number of hops to traverse (default 2, max 4).
435
+ A depth of 0 returns only the starting entity.
436
+
437
+ Returns:
438
+ JSON object with "nodes" (entities) and "edges" lists.
439
+ """
440
+ depth = min(max(max_depth, 0), 4)
441
+ result = await traverse_graph(entity_id, max_depth=depth)
442
+ # Trim content on nodes to keep response size manageable
443
+ for node in result.get("nodes", []):
444
+ if node.get("content") and len(str(node["content"])) > 300:
445
+ node["content"] = str(node["content"])[:300] + "…"
446
+ return json.dumps(result, default=str)
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # fetch_entity — trigger connector re-ingestion
451
+ # ---------------------------------------------------------------------------
452
+
453
+
454
+ @mcp.tool()
455
+ async def fetch_entity_tool(platform: str, resource_id: str) -> str:
456
+ """
457
+ Trigger a connector fetch for a platform entity.
458
+
459
+ Forces re-ingestion of a specific resource from its source platform,
460
+ persisting updated content, people, and edges before returning.
461
+
462
+ Args:
463
+ platform: Platform name (e.g. "gdocs", "slack", "discord", "gmail", "rss").
464
+ resource_id: Platform-specific entity ID.
465
+
466
+ Returns:
467
+ JSON object with counts of ingested entities, persons, and edges.
468
+ """
469
+ from agentgraph.graph.fetch import fetch_entity
470
+
471
+ try:
472
+ result = await fetch_entity(platform, resource_id)
473
+ return json.dumps(result)
474
+ except ValueError as exc:
475
+ return json.dumps({"error": str(exc)})
476
+
477
+
478
+ # ---------------------------------------------------------------------------
479
+ # fetch_entity_by_id — re-ingest by internal UUID
480
+ # ---------------------------------------------------------------------------
481
+
482
+
483
+ @mcp.tool()
484
+ async def fetch_entity_by_id_tool(entity_id: str) -> str:
485
+ """
486
+ Trigger a connector fetch for an entity by its internal UUID.
487
+
488
+ Looks up the entity's platform and platform-specific ID, then forces
489
+ re-ingestion from the source platform and persists the returned batch
490
+ before returning.
491
+
492
+ Args:
493
+ entity_id: Internal entity UUID (the id field from graph nodes).
494
+
495
+ Returns:
496
+ JSON object with counts of ingested entities, persons, and edges,
497
+ or an error message if the entity is not found.
498
+ """
499
+ from agentgraph.graph.fetch import fetch_entity_by_id
500
+
501
+ try:
502
+ result = await fetch_entity_by_id(entity_id)
503
+ return json.dumps(result)
504
+ except ValueError as exc:
505
+ return json.dumps({"error": str(exc)})
506
+
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # poll_connectors — trigger background connector polling
510
+ # ---------------------------------------------------------------------------
511
+
512
+
513
+ @mcp.tool()
514
+ async def poll_connectors_tool(source: str | None = None) -> str:
515
+ """
516
+ Trigger a background poll for one connector or all polling connectors.
517
+
518
+ This is the MCP equivalent of:
519
+ agentgraph poll [<source>]
520
+
521
+ Args:
522
+ source: Optional connector source to poll. When omitted, all connectors
523
+ with poll_interval configured are polled.
524
+
525
+ Returns:
526
+ JSON object with queued, already-running, and skipped connector sources,
527
+ or an error if a requested connector source is not registered.
528
+ """
529
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors, get_connector
530
+ from agentgraph.server.sync import schedule_poll_connector
531
+
532
+ bootstrap()
533
+ if source is not None:
534
+ connector = get_connector(source)
535
+ if connector is None:
536
+ return json.dumps({"error": f"No connector registered for source {source!r}"})
537
+ connectors = [connector]
538
+ else:
539
+ connectors = get_all_connectors()
540
+
541
+ polled: list[str] = []
542
+ already_running: list[str] = []
543
+ skipped: list[dict[str, str | None]] = []
544
+ for connector in connectors:
545
+ if connector.poll_interval is None:
546
+ continue
547
+ result = await schedule_poll_connector(connector)
548
+ if result["status"] == "queued":
549
+ polled.append(connector.source)
550
+ elif result["status"] == "already_running":
551
+ already_running.append(connector.source)
552
+ else:
553
+ skipped.append({"source": connector.source, "reason": result["reason"]})
554
+
555
+ return json.dumps({"polled": polled, "already_running": already_running, "skipped": skipped})
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # ingest_connector — trigger background bulk ingest
560
+ # ---------------------------------------------------------------------------
561
+
562
+
563
+ @mcp.tool()
564
+ async def ingest_connector_tool(source: str) -> str:
565
+ """
566
+ Trigger a background one-shot bulk ingest for a connector.
567
+
568
+ This is the MCP equivalent of:
569
+ agentgraph ingest <source>
570
+
571
+ Args:
572
+ source: Connector source to ingest.
573
+
574
+ Returns:
575
+ JSON object with source and status, or an error if the connector source
576
+ is not registered.
577
+ """
578
+ from agentgraph.connectors.registry import bootstrap, get_connector
579
+ from agentgraph.server.sync import run_ingest
580
+
581
+ bootstrap()
582
+ connector = get_connector(source)
583
+ if connector is None:
584
+ return json.dumps({"error": f"No connector registered for source {source!r}"})
585
+
586
+ asyncio.create_task(run_ingest(connector))
587
+ return json.dumps({"source": source, "status": "started"})
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # download_entity — authenticated source-file download
592
+ # ---------------------------------------------------------------------------
593
+
594
+
595
+ @mcp.tool()
596
+ async def download_entity_tool(entity_id: str, output_path: str | None = None) -> str:
597
+ """
598
+ Download an entity's source file using the connector's stored auth.
599
+
600
+ Supports entity UUIDs, UUID prefixes, and platform refs such as
601
+ "gdrive/file-id" or "gmail/document/attachment/<message-id>/<attachment-id>"
602
+ when those resolve to a graph entity. The file is written to output_path
603
+ when supplied, or to the MCP server's current directory using the source
604
+ filename.
605
+
606
+ Args:
607
+ entity_id: Entity UUID, UUID prefix, or platform/entity_id reference.
608
+ output_path: Optional output file path or directory.
609
+
610
+ Returns:
611
+ JSON object with path, byte count, filename, platform, and MIME type, or
612
+ an error message if the entity or connector cannot be downloaded.
613
+ """
614
+ from agentgraph.graph.download import download_entity
615
+
616
+ try:
617
+ result = await download_entity(entity_id, output_path)
618
+ return json.dumps(result, default=str)
619
+ except ValueError as exc:
620
+ return json.dumps({"error": str(exc)})
621
+
622
+
623
+ # ---------------------------------------------------------------------------
624
+ # bookmark_entity — protect an entity from garbage collection
625
+ # ---------------------------------------------------------------------------
626
+
627
+
628
+ @mcp.tool()
629
+ async def bookmark_entity_tool(entity_id: str, bookmarked: bool = True) -> str:
630
+ """
631
+ Set or remove bookmark protection for an entity or URL.
632
+
633
+ Supports entity UUIDs, UUID prefixes, and platform refs such as
634
+ "gdrive/file-id" when those resolve to a graph entity. HTTP(S) URLs are
635
+ fetched through an owning connector when possible when adding a bookmark,
636
+ otherwise through the generic web connector.
637
+
638
+ Args:
639
+ entity_id: Entity UUID, UUID prefix, platform/entity_id reference, or URL.
640
+ bookmarked: True to add bookmark protection; false to remove it.
641
+
642
+ Returns:
643
+ JSON object for the updated entity with its bookmark state, or an error
644
+ message if the entity cannot be found.
645
+ """
646
+ from agentgraph.graph.bookmark import bookmark_target, set_entity_bookmark
647
+
648
+ try:
649
+ result = (
650
+ await bookmark_target(entity_id)
651
+ if bookmarked
652
+ else await set_entity_bookmark(entity_id, False)
653
+ )
654
+ return json.dumps(result, default=str)
655
+ except ValueError as exc:
656
+ return json.dumps({"error": str(exc)})
657
+
658
+
659
+ # ---------------------------------------------------------------------------
660
+ # delete_entity — remove an entity
661
+ # ---------------------------------------------------------------------------
662
+
663
+
664
+ @mcp.tool()
665
+ async def delete_entity_tool(entity_id: str) -> str:
666
+ """
667
+ Delete an entity from the graph.
668
+
669
+ Supports entity UUIDs, UUID prefixes, platform refs such as
670
+ "gdrive/file-id", and HTTP(S) URLs when those resolve to a graph entity.
671
+ Connected edges are deleted with the entity.
672
+
673
+ Args:
674
+ entity_id: Entity UUID, UUID prefix, platform/entity_id reference, or URL.
675
+
676
+ Returns:
677
+ JSON object with deleted=true and the deleted entity, or an error
678
+ message if the entity cannot be found.
679
+ """
680
+ from agentgraph.graph.delete import delete_entity
681
+
682
+ try:
683
+ result = await delete_entity(entity_id)
684
+ return json.dumps(result, default=str)
685
+ except ValueError as exc:
686
+ return json.dumps({"error": str(exc)})
687
+
688
+
689
+ # ---------------------------------------------------------------------------
690
+ # unify_persons — manually merge duplicate Person entities
691
+ # ---------------------------------------------------------------------------
692
+
693
+
694
+ @mcp.tool()
695
+ async def unify_persons_tool(
696
+ primary_entity_id: str,
697
+ duplicate_entity_ids: list[str],
698
+ ) -> str:
699
+ """
700
+ Merge duplicate Person entities that refer to the same human.
701
+
702
+ Use this only when the user has confirmed that the Person entities are the
703
+ same person. The primary Person keeps its ID; edges from duplicate Persons
704
+ are rewired to the primary; duplicate metadata such as platform user IDs is
705
+ folded into the primary; duplicate Person entities are removed.
706
+
707
+ Args:
708
+ primary_entity_id: Person entity ID, UUID prefix, or platform ref to keep.
709
+ duplicate_entity_ids: Duplicate Person entity IDs, UUID prefixes, or
710
+ platform refs to merge into the primary.
711
+
712
+ Returns:
713
+ JSON object with the updated primary Person and merged duplicate IDs,
714
+ or an error message if any entity is missing or is not a Person.
715
+ """
716
+ from agentgraph.graph.person import unify_persons
717
+
718
+ try:
719
+ result = await unify_persons(primary_entity_id, duplicate_entity_ids)
720
+ return json.dumps(result, default=str)
721
+ except ValueError as exc:
722
+ return json.dumps({"error": str(exc)})
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # query_by_filter — type + metadata filters
727
+ # ---------------------------------------------------------------------------
728
+
729
+
730
+ @mcp.tool()
731
+ async def query_by_filter_tool(
732
+ entity_type: str,
733
+ filters: dict[str, Any] | None = None,
734
+ since: str | None = None,
735
+ authored_by_me: bool = False,
736
+ has_attachments: bool = False,
737
+ limit: int = 50,
738
+ order_by: str = "created_at",
739
+ refresh: bool = False,
740
+ ) -> str:
741
+ """
742
+ Query entities by type with optional filters.
743
+
744
+ Useful for listing all messages in a specific channel, all documents
745
+ on a platform, activity within a time window, or content authored by
746
+ the current user.
747
+
748
+ Entity types and what they contain:
749
+ - Message: chat messages from Discord, Slack, etc. This is
750
+ where chat image and file uploads live — attachments are stored
751
+ in metadata.attachments as a JSON array with fields: url,
752
+ filename, content_type, width, height. To find images or
753
+ uploaded files, query Message (not Document) and set
754
+ has_attachments=True.
755
+ - Document: text documents such as Google Docs, plus Gmail attachment
756
+ stubs referenced by their owning Thread. Gmail attachment Document
757
+ stubs can be passed to download_entity_tool.
758
+ - Spreadsheet: Google Sheets or Excel files.
759
+ - Folder: a Google Drive folder containing other entities.
760
+ - Channel: a chat channel or DM thread (Discord, Slack, etc.).
761
+ - Thread: an email thread (Gmail).
762
+ - Task: a task or to-do item (e.g. from a project tracker).
763
+ - Project: a project or repository container.
764
+
765
+ Example — find images uploaded in the last 7 days:
766
+ entity_type="Message", has_attachments=True, since="7d"
767
+
768
+ Args:
769
+ entity_type: Entity type to query. See above for what each type
770
+ contains. Use "Message" to find chat uploads; use "Document" with
771
+ platform="gmail" to find Gmail attachment stubs.
772
+ filters: Optional dict of key=value filters. Known columns
773
+ (platform, platform_entity_id) are applied as column filters;
774
+ all other keys are matched against the metadata JSONB field.
775
+ since: Optional time cutoff — ISO timestamp or relative duration
776
+ like "12h", "30m", "2d". Only returns entities updated after
777
+ this time.
778
+ authored_by_me: If true, only return entities with an authored
779
+ edge from the current user (resolved from stored credentials).
780
+ has_attachments: If true, only return Message entities that have
781
+ at least one chat file or image attachment in metadata.attachments.
782
+ Ignored for non-Message entity types. Gmail attachments are
783
+ Document stubs instead.
784
+ limit: Maximum number of results (default 50).
785
+ order_by: Column to sort by descending (default "created_at").
786
+ refresh: If true, let connectors refresh or enrich connector-owned
787
+ presentation metadata before returning. Defaults to false to keep
788
+ queries responsive.
789
+
790
+ Returns:
791
+ JSON array of matching entities. For Message entities with
792
+ attachments, each result includes metadata.attachments — a JSON
793
+ string that decodes to a list of {url, filename, content_type,
794
+ width?, height?} objects. Connectors may refresh or enrich
795
+ connector-owned metadata before results are returned.
796
+ """
797
+ str_filters: dict[str, str] = {k: str(v) for k, v in (filters or {}).items()}
798
+ results = await query_by_filter(
799
+ entity_type,
800
+ filters=str_filters,
801
+ limit=limit,
802
+ order_by=order_by,
803
+ since=since,
804
+ authored_by_me=authored_by_me,
805
+ has_attachments=has_attachments,
806
+ )
807
+ for result in results:
808
+ _truncate_content(result)
809
+ if refresh:
810
+ await _enrich_results(results)
811
+ return json.dumps(results, default=str)