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
agentgraph/cli.py ADDED
@@ -0,0 +1,721 @@
1
+ """AgentGraph CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json as _json
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager
9
+ from typing import TYPE_CHECKING, cast
10
+
11
+ import typer
12
+
13
+ if TYPE_CHECKING:
14
+ from agentgraph.connectors.base import BaseConnector
15
+
16
+ app = typer.Typer(
17
+ name="agentgraph",
18
+ help="Local knowledge graph for AI agents.",
19
+ no_args_is_help=True,
20
+ )
21
+ auth_app = typer.Typer(
22
+ help="Show auth provider state or manage connector authentication.",
23
+ invoke_without_command=True,
24
+ no_args_is_help=False,
25
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
26
+ )
27
+ app.add_typer(auth_app, name="auth")
28
+
29
+
30
+ @contextmanager
31
+ def _readable_credentials() -> Iterator[None]:
32
+ """Report a damaged credentials file as an error instead of a traceback."""
33
+ from agentgraph.auth.credentials import CredentialsFileError
34
+
35
+ try:
36
+ yield
37
+ except CredentialsFileError as exc:
38
+ typer.echo(str(exc), err=True)
39
+ raise typer.Exit(code=1) from exc
40
+
41
+
42
+ def _status_label(status: str) -> str:
43
+ return {"ok": "authenticated", "missing": "not authenticated", "invalid": "INVALID"}.get(
44
+ status, status
45
+ )
46
+
47
+
48
+ def _remove_auth_credentials(provider: str, account_id: str | None) -> dict[str, object]:
49
+ from agentgraph.auth.credentials import remove_platform, remove_platform_account
50
+
51
+ removed = (
52
+ remove_platform_account(provider, account_id)
53
+ if account_id is not None
54
+ else remove_platform(provider)
55
+ )
56
+ result: dict[str, object] = {
57
+ "provider": provider,
58
+ "removed": removed,
59
+ }
60
+ if account_id is not None:
61
+ result["account_id"] = account_id
62
+ return result
63
+
64
+
65
+ def _parse_auth_args(
66
+ args: list[str],
67
+ *,
68
+ account: str | None,
69
+ json: bool,
70
+ verify: bool,
71
+ add: bool,
72
+ provider_args: list[str] | None = None,
73
+ ) -> tuple[list[str], str | None, bool, bool, bool, list[str], str | None]:
74
+ positionals: list[str] = []
75
+ parsed_account = account
76
+ parsed_json = json
77
+ parsed_verify = verify
78
+ parsed_add = add
79
+ parsed_provider_args = list(provider_args or [])
80
+ error: str | None = None
81
+ i = 0
82
+ while i < len(args):
83
+ arg = args[i]
84
+ if arg == "--json":
85
+ parsed_json = True
86
+ elif arg == "--verify":
87
+ parsed_verify = True
88
+ elif arg == "--add":
89
+ parsed_add = True
90
+ elif arg == "--account":
91
+ if i + 1 >= len(args):
92
+ error = "--account requires an account ID"
93
+ break
94
+ parsed_account = args[i + 1]
95
+ i += 1
96
+ elif arg.startswith("--account="):
97
+ parsed_account = arg.split("=", 1)[1]
98
+ elif arg.startswith("-"):
99
+ if not positionals or positionals[0] in ("status", "remove"):
100
+ error = f"Unknown option for auth: {arg}"
101
+ break
102
+ parsed_provider_args.append(arg)
103
+ else:
104
+ if len(positionals) == 1 and positionals[0] not in ("status", "remove"):
105
+ parsed_provider_args.append(arg)
106
+ else:
107
+ positionals.append(arg)
108
+ i += 1
109
+ return (
110
+ positionals,
111
+ parsed_account,
112
+ parsed_json,
113
+ parsed_verify,
114
+ parsed_add,
115
+ parsed_provider_args,
116
+ error,
117
+ )
118
+
119
+
120
+ @auth_app.callback()
121
+ def auth(
122
+ auth_args: list[str] | None = typer.Argument(
123
+ None,
124
+ help="Use 'status', 'remove <provider>', or an auth provider such as google, slack, or discord",
125
+ ),
126
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
127
+ verify: bool = typer.Option(
128
+ False, "--verify", help="Live-check credentials with provider APIs"
129
+ ),
130
+ add: bool = typer.Option(
131
+ False, "--add", help="Add another authenticated account for this provider"
132
+ ),
133
+ account: str | None = typer.Option(
134
+ None, "--account", help="Re-authenticate a specific account ID"
135
+ ),
136
+ ) -> None:
137
+ """Show auth status or authenticate a provider."""
138
+ (
139
+ args,
140
+ parsed_account,
141
+ parsed_json,
142
+ parsed_verify,
143
+ parsed_add,
144
+ parsed_provider_args,
145
+ parse_error,
146
+ ) = _parse_auth_args(
147
+ auth_args or [],
148
+ account=account,
149
+ json=json,
150
+ verify=verify,
151
+ add=add,
152
+ )
153
+ if parse_error is not None:
154
+ typer.echo(parse_error, err=True)
155
+ raise typer.Exit(code=1)
156
+
157
+ target = args[0] if args else None
158
+ if target == "remove":
159
+ if len(args) > 2:
160
+ typer.echo(f"Unexpected argument for auth remove: {args[2]}", err=True)
161
+ raise typer.Exit(code=1)
162
+ if len(args) < 2:
163
+ typer.echo(
164
+ "Usage: agentgraph auth remove <provider> [--account <account-id>] [--json]",
165
+ err=True,
166
+ )
167
+ raise typer.Exit(code=1)
168
+ provider = args[1]
169
+ with _readable_credentials():
170
+ result = _remove_auth_credentials(provider, parsed_account)
171
+
172
+ if parsed_json:
173
+ typer.echo(_json.dumps(result, indent=2))
174
+ return
175
+
176
+ if result["removed"]:
177
+ if parsed_account is None:
178
+ typer.echo(f"Removed stored credentials for {provider}.")
179
+ else:
180
+ typer.echo(f"Removed stored credentials for {provider} account {parsed_account}.")
181
+ return
182
+
183
+ if parsed_account is None:
184
+ typer.echo(f"No stored credentials found for {provider}.", err=True)
185
+ else:
186
+ typer.echo(
187
+ f"No stored credentials found for {provider} account {parsed_account}.",
188
+ err=True,
189
+ )
190
+ raise typer.Exit(code=1)
191
+
192
+ if len(args) > 1:
193
+ typer.echo(f"Unexpected argument for auth: {args[1]}", err=True)
194
+ raise typer.Exit(code=1)
195
+
196
+ if target not in (None, "status"):
197
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors
198
+ from agentgraph.connectors.status import (
199
+ auth_provider_connectors,
200
+ run_auth_provider_flow,
201
+ )
202
+
203
+ bootstrap()
204
+ all_connectors = get_all_connectors()
205
+ grouped = auth_provider_connectors(all_connectors)
206
+ seen = {label: connectors[0] for label, connectors in grouped.items()}
207
+
208
+ if target not in seen:
209
+ available = ", ".join(["status", *sorted(seen)])
210
+ typer.echo(f"Unknown auth target '{target}'. Available: {available}", err=True)
211
+ raise typer.Exit(code=1)
212
+
213
+ with _readable_credentials():
214
+ try:
215
+ run_auth_provider_flow(
216
+ all_connectors,
217
+ target,
218
+ account_id=parsed_account,
219
+ add=parsed_add,
220
+ args=parsed_provider_args,
221
+ )
222
+ except ValueError as exc:
223
+ typer.echo(str(exc), err=True)
224
+ raise typer.Exit(code=2) from exc
225
+ return
226
+
227
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors
228
+ from agentgraph.connectors.status import auth_provider_status_items
229
+
230
+ bootstrap()
231
+ with _readable_credentials():
232
+ items = asyncio.run(auth_provider_status_items(get_all_connectors(), verify=parsed_verify))
233
+
234
+ if parsed_json:
235
+ typer.echo(_json.dumps(items, indent=2))
236
+ return
237
+
238
+ for item in items:
239
+ status = str(item["auth_status"])
240
+ detail = item["auth_detail"]
241
+ auth_state = _status_label(status)
242
+ if detail:
243
+ auth_state = (
244
+ f"{auth_state} ({detail})" if status != "ok" else f"{auth_state} as {detail}"
245
+ )
246
+ connectors = ", ".join(str(source) for source in cast(list[object], item["connectors"]))
247
+ typer.echo(f" {item['provider']:<12} {item['description']}")
248
+ typer.echo(f" {'':<12} auth: {auth_state} | connectors: {connectors}")
249
+ accounts = cast(list[dict[str, object]], item.get("accounts") or [])
250
+ for account_row in accounts:
251
+ account_status = str(account_row["auth_status"])
252
+ account_auth = _status_label(account_status)
253
+ account_detail = account_row.get("auth_detail")
254
+ if account_detail:
255
+ account_auth = (
256
+ f"{account_auth} ({account_detail})"
257
+ if account_status != "ok"
258
+ else f"{account_auth} as {account_detail}"
259
+ )
260
+ typer.echo(
261
+ f" {'':<12} account: {account_row['label']} [{account_row['account_id']}]"
262
+ f" | method: {account_row.get('auth_method') or 'unknown'} | {account_auth}"
263
+ )
264
+
265
+
266
+ @app.command(
267
+ "connector",
268
+ context_settings={
269
+ "allow_extra_args": True,
270
+ "ignore_unknown_options": True,
271
+ "help_option_names": [],
272
+ },
273
+ )
274
+ def connector_command(
275
+ ctx: typer.Context,
276
+ source: str | None = typer.Argument(None, help="Connector source, e.g. rss"),
277
+ args: list[str] | None = typer.Argument(None, help="Connector-owned command and arguments"),
278
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
279
+ help: bool = typer.Option(False, "--help", help="Show this message and exit."),
280
+ ) -> None:
281
+ """Run a connector-owned command."""
282
+ from agentgraph.connectors.registry import bootstrap, get_connector
283
+
284
+ if source is None:
285
+ from typer.rich_utils import rich_format_help
286
+
287
+ rich_format_help(obj=ctx.command, ctx=ctx, markup_mode="rich")
288
+ return
289
+
290
+ bootstrap()
291
+ connector = get_connector(source)
292
+ if connector is None:
293
+ typer.echo(f"Unknown connector '{source}'", err=True)
294
+ raise typer.Exit(code=1)
295
+
296
+ if help:
297
+ typer.echo(type(connector).cli_help())
298
+ return
299
+
300
+ command_args = args or []
301
+ try:
302
+ result = type(connector).run_cli_command(command_args)
303
+ effects = type(connector).command_effects(command_args, result)
304
+ if effects.poll:
305
+ from agentgraph.cli_query import queue_connector_poll
306
+
307
+ result["poll"] = queue_connector_poll(connector.source)
308
+ except NotImplementedError as exc:
309
+ typer.echo(str(exc), err=True)
310
+ raise typer.Exit(code=1) from exc
311
+ except (OSError, ValueError) as exc:
312
+ typer.echo(str(exc), err=True)
313
+ raise typer.Exit(code=1) from exc
314
+
315
+ if json:
316
+ typer.echo(_json.dumps(result, indent=2))
317
+ return
318
+ typer.echo(type(connector).format_cli_result(result))
319
+
320
+
321
+ @app.command()
322
+ def connectors(
323
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
324
+ verify: bool = typer.Option(
325
+ False, "--verify", help="Live-check connector credentials with provider APIs"
326
+ ),
327
+ ) -> None:
328
+ """List installed connectors and their sync status."""
329
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors
330
+ from agentgraph.connectors.status import connector_status_items
331
+ from agentgraph.core.runtime import backend_context
332
+
333
+ bootstrap()
334
+ all_connectors = get_all_connectors()
335
+
336
+ async def _gather() -> list[dict[str, object]]:
337
+ async with backend_context() as backend:
338
+ return await connector_status_items(all_connectors, backend, verify=verify)
339
+
340
+ items = asyncio.run(_gather())
341
+
342
+ if json:
343
+ typer.echo(_json.dumps(items, indent=2))
344
+ return
345
+
346
+ for item in items:
347
+ sync = str(item["sync"])
348
+ last_sync = str(item["last_sync"])
349
+ desc = item["description"] or item["source"]
350
+ typer.echo(f" {item['source']:<12} {desc}")
351
+ status = item["auth_status"]
352
+ if status is None:
353
+ typer.echo(f" {'':<12} sync: {sync} | last sync: {last_sync}")
354
+ continue
355
+ status_label = str(status)
356
+ detail = item["auth_detail"]
357
+ auth = _status_label(status_label)
358
+ if detail:
359
+ auth = f"{auth} ({detail})" if status_label != "ok" else f"{auth} as {detail}"
360
+ typer.echo(
361
+ f" {'':<12} auth: {auth} via {item['auth_provider']} | sync: {sync} | last sync: {last_sync}"
362
+ )
363
+
364
+
365
+ @app.command()
366
+ def serve(
367
+ reload: bool = typer.Option(False, "--reload", "-r", help="Auto-reload on code changes"),
368
+ ) -> None:
369
+ """Start the AgentGraph backend server."""
370
+ import uvicorn
371
+
372
+ from agentgraph.config import get_settings
373
+ from agentgraph.logging import configure_logging
374
+
375
+ settings = get_settings()
376
+ configure_logging(settings.log_level, settings.log_file)
377
+ uvicorn.run(
378
+ "agentgraph.server.app:app",
379
+ host=settings.server_host,
380
+ port=settings.server_port,
381
+ reload=reload,
382
+ )
383
+
384
+
385
+ @app.command()
386
+ def search(
387
+ query: str = typer.Argument(..., help="Search query"),
388
+ type: list[str] = typer.Option([], "--type", "-t", help="Filter by entity type"),
389
+ platform: str | None = typer.Option(
390
+ None, "--platform", "-p", help="Scope to a single platform (e.g. slack, discord)"
391
+ ),
392
+ limit: int = typer.Option(10, "--limit", "-n", help="Maximum results"),
393
+ min_score: float = typer.Option(0.03, "--min-score", help="Minimum relevance score (0–1)"),
394
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
395
+ ) -> None:
396
+ """Search the knowledge graph."""
397
+ from agentgraph.cli_query import cmd_search
398
+
399
+ cmd_search(
400
+ query=query,
401
+ entity_types=type,
402
+ platform=platform,
403
+ limit=limit,
404
+ min_score=min_score,
405
+ as_json=json,
406
+ )
407
+
408
+
409
+ @app.command()
410
+ def get(
411
+ entity_id: str = typer.Argument(..., help="Entity ID, platform ref, or URL"),
412
+ resolve: bool = typer.Option(
413
+ False, "--resolve", "-r", help="Fetch from source if entity is a stub (no content)"
414
+ ),
415
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
416
+ ) -> None:
417
+ """Fetch full details for an existing entity."""
418
+ from agentgraph.cli_query import cmd_get
419
+
420
+ cmd_get(entity_id=entity_id, as_json=json, resolve=resolve)
421
+
422
+
423
+ @app.command()
424
+ def edges(
425
+ entity_id: str = typer.Argument(..., help="Entity ID"),
426
+ type: str = typer.Option("", "--type", "-t", help="Filter by edge type"),
427
+ direction: str = typer.Option("both", "--direction", "-d", help="in | out | both"),
428
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
429
+ ) -> None:
430
+ """List edges for an entity."""
431
+ from agentgraph.cli_query import cmd_edges
432
+
433
+ cmd_edges(entity_id=entity_id, edge_type=type or None, direction=direction, as_json=json)
434
+
435
+
436
+ @app.command()
437
+ def traverse(
438
+ entity_id: str = typer.Argument(..., help="Start entity ID"),
439
+ depth: int = typer.Option(2, "--depth", "-d", help="Maximum traversal depth"),
440
+ resolve: bool = typer.Option(
441
+ False, "--resolve", "-r", help="Fetch stub nodes from source before returning"
442
+ ),
443
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
444
+ ) -> None:
445
+ """Traverse the graph from an entity."""
446
+ from agentgraph.cli_query import cmd_traverse
447
+
448
+ cmd_traverse(entity_id=entity_id, max_depth=depth, as_json=json, resolve=resolve)
449
+
450
+
451
+ @app.command()
452
+ def fetch(
453
+ platform: str = typer.Argument(..., help="Platform name (e.g. gdocs, slack, discord, rss)"),
454
+ resource_id: str = typer.Argument(..., help="Platform-specific entity ID"),
455
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
456
+ ) -> None:
457
+ """Trigger a connector fetch for a platform entity."""
458
+ from agentgraph.cli_query import cmd_fetch
459
+
460
+ cmd_fetch(platform=platform, resource_id=resource_id, as_json=json)
461
+
462
+
463
+ @app.command("fetch-entity")
464
+ def fetch_entity_cmd(
465
+ entity_id: str = typer.Argument(..., help="Internal entity UUID"),
466
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
467
+ ) -> None:
468
+ """Trigger a connector re-fetch for an entity by its internal ID."""
469
+ from agentgraph.cli_query import cmd_fetch_entity
470
+
471
+ cmd_fetch_entity(entity_id=entity_id, as_json=json)
472
+
473
+
474
+ @app.command()
475
+ def download(
476
+ entity_id: str = typer.Argument(..., help="Entity ID, UUID prefix, or platform ref"),
477
+ output: str | None = typer.Option(None, "--output", "-o", help="Output file path or directory"),
478
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
479
+ ) -> None:
480
+ """Download an entity's source file using connector auth."""
481
+ from agentgraph.cli_query import cmd_download
482
+
483
+ cmd_download(entity_id=entity_id, output_path=output, as_json=json)
484
+
485
+
486
+ @app.command()
487
+ def bookmark(
488
+ target: str = typer.Argument(..., help="Entity ID, UUID prefix, platform ref, or URL"),
489
+ remove: bool = typer.Option(
490
+ False, "--remove", help="Remove bookmark protection instead of adding it"
491
+ ),
492
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
493
+ ) -> None:
494
+ """Set or remove bookmark protection for an entity or URL."""
495
+ from agentgraph.cli_query import cmd_bookmark
496
+
497
+ cmd_bookmark(target=target, bookmarked=not remove, as_json=json)
498
+
499
+
500
+ @app.command("delete")
501
+ def delete_cmd(
502
+ target: str = typer.Argument(..., help="Entity ID, UUID prefix, platform ref, or URL"),
503
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
504
+ ) -> None:
505
+ """Delete an entity from the graph."""
506
+ from agentgraph.cli_query import cmd_delete
507
+
508
+ cmd_delete(target=target, as_json=json)
509
+
510
+
511
+ @app.command("unify-persons")
512
+ def unify_persons_cmd(
513
+ primary_entity_id: str = typer.Argument(..., help="Person entity to keep"),
514
+ duplicate_entity_ids: list[str] = typer.Argument(
515
+ ..., help="Duplicate Person entities to merge into the primary"
516
+ ),
517
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
518
+ ) -> None:
519
+ """Merge duplicate Person entities that refer to the same human."""
520
+ from agentgraph.cli_query import cmd_unify_persons
521
+
522
+ cmd_unify_persons(
523
+ primary_entity_id=primary_entity_id,
524
+ duplicate_entity_ids=duplicate_entity_ids,
525
+ as_json=json,
526
+ )
527
+
528
+
529
+ @app.command()
530
+ def onboard() -> None:
531
+ """Interactive setup: authenticate with each installed connector."""
532
+ from agentgraph.connectors.registry import bootstrap, get_all_connectors
533
+
534
+ bootstrap()
535
+ # Dedup by auth_label so multi-connector platforms (e.g. Google) appear once.
536
+ seen: dict[str, BaseConnector] = {}
537
+ for connector in get_all_connectors():
538
+ label: str = getattr(connector, "auth_label", None) or connector.source
539
+ if label not in seen:
540
+ seen[label] = connector
541
+
542
+ steps = list(seen.items())
543
+ total = len(steps)
544
+
545
+ typer.echo("=== AgentGraph Setup ===\n")
546
+
547
+ for i, (label, connector) in enumerate(steps, 1):
548
+ prompt: str = getattr(connector, "onboard_prompt", None) or f"Set up {label}?"
549
+ description: str = getattr(connector, "auth_description", None) or label.title()
550
+ typer.echo(f"Step {i}/{total}: {description}")
551
+ if typer.confirm(f" {prompt}", default=True):
552
+ type(connector).run_auth_flow()
553
+ else:
554
+ typer.echo(" Skipped.")
555
+ if i < total:
556
+ typer.echo()
557
+
558
+ typer.echo("\nSetup complete. Run `agentgraph serve` to start the server.")
559
+
560
+
561
+ @app.command()
562
+ def mcp_config() -> None:
563
+ """Print MCP client setup instructions."""
564
+ import json
565
+ import sys
566
+
567
+ binary = sys.argv[0]
568
+
569
+ config = {
570
+ "mcpServers": {
571
+ "agentgraph": {
572
+ "command": binary,
573
+ "args": ["mcp-serve"],
574
+ }
575
+ }
576
+ }
577
+
578
+ typer.echo("\nFor stdio MCP clients, add this to your MCP client config:\n")
579
+ typer.echo(" Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json")
580
+ typer.echo(" Claude Code: ~/.claude/mcp.json (or .claude/mcp.json in your project)\n")
581
+ typer.echo(json.dumps(config, indent=2))
582
+ typer.echo()
583
+ typer.echo("For ChatGPT developer mode, do not use the stdio JSON above.")
584
+ typer.echo("Run a streamable HTTP MCP server:")
585
+ typer.echo(f" {binary} mcp-serve --transport streamable-http --port 8808")
586
+ typer.echo("Local endpoint:")
587
+ typer.echo(" http://127.0.0.1:8808/mcp")
588
+ typer.echo("ChatGPT requires a reachable HTTPS URL. Use Secure MCP Tunnel, ngrok,")
589
+ typer.echo("or Cloudflare Tunnel, then create an app/connector in ChatGPT Developer mode")
590
+ typer.echo("with the public URL ending in /mcp, for example:")
591
+ typer.echo(" https://your-tunnel.example/mcp")
592
+ typer.echo()
593
+ typer.echo("SSE is also supported by the server if your MCP client needs it:")
594
+ typer.echo(f" {binary} mcp-serve --transport sse --port 8808")
595
+ typer.echo(" http://127.0.0.1:8808/sse")
596
+ typer.echo()
597
+
598
+
599
+ @app.command()
600
+ def install_skill(
601
+ skill: str = typer.Argument("graph", help="Bundled skill to install"),
602
+ target: str = typer.Option("user", "--target", help="Install target: user or project"),
603
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing installed skill"),
604
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
605
+ ) -> None:
606
+ """Install a bundled AgentGraph skill into an agent skill directory."""
607
+ from agentgraph.skills import SkillInstallError
608
+ from agentgraph.skills import install_skill as install_agentgraph_skill
609
+
610
+ if target not in ("user", "project"):
611
+ typer.echo("Target must be 'user' or 'project'", err=True)
612
+ raise typer.Exit(code=1)
613
+
614
+ try:
615
+ result = install_agentgraph_skill(skill, target=target, force=force)
616
+ except SkillInstallError as exc:
617
+ typer.echo(str(exc), err=True)
618
+ raise typer.Exit(code=1) from exc
619
+
620
+ if json:
621
+ typer.echo(_json.dumps(result.to_dict(), indent=2))
622
+ return
623
+
624
+ typer.echo(f"Installed AgentGraph skill '{result.skill}' to {result.destination}")
625
+
626
+
627
+ @app.command()
628
+ def mcp_serve(
629
+ transport: str = typer.Option(
630
+ "stdio", "--transport", help="Transport: stdio, sse, or streamable-http"
631
+ ),
632
+ port: int = typer.Option(8808, "--port", help="Port for sse / streamable-http transports"),
633
+ host: str = typer.Option(
634
+ "127.0.0.1", "--host", help="Host to bind for sse / streamable-http transports"
635
+ ),
636
+ ) -> None:
637
+ """Start the AgentGraph MCP server."""
638
+ import asyncio
639
+
640
+ from agentgraph.core.context import set_backend
641
+ from agentgraph.core.runtime import create_backend
642
+
643
+ backend = create_backend()
644
+ asyncio.run(backend.initialize())
645
+ set_backend(backend)
646
+
647
+ from agentgraph.mcp.server import mcp
648
+
649
+ if transport in ("sse", "streamable-http"):
650
+ mcp.settings.port = port
651
+ mcp.settings.host = host
652
+ # Clear DNS rebinding protection when binding to a non-localhost address
653
+ # (the singleton is initialized with host=127.0.0.1 which enables it by default)
654
+ if host not in ("127.0.0.1", "localhost", "::1"):
655
+ mcp.settings.transport_security = None
656
+ mcp.run(transport=transport) # type: ignore[arg-type]
657
+
658
+
659
+ @app.command()
660
+ def poll(
661
+ source: str | None = typer.Argument(
662
+ None, help="Connector source to poll (e.g. slack, gmail, rss). Omit to poll all."
663
+ ),
664
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
665
+ ) -> None:
666
+ """Trigger a background poll for one or all connectors."""
667
+ from agentgraph.cli_query import cmd_poll
668
+
669
+ cmd_poll(source=source, as_json=json)
670
+
671
+
672
+ @app.command()
673
+ def ingest(
674
+ source: str = typer.Argument(..., help="Connector source to ingest (e.g. gmail, rss)"),
675
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
676
+ ) -> None:
677
+ """Run a one-shot bulk ingest for a connector (all data within the retention window)."""
678
+ from agentgraph.cli_query import cmd_ingest
679
+
680
+ cmd_ingest(source=source, as_json=json)
681
+
682
+
683
+ @app.command()
684
+ def query(
685
+ entity_type: str = typer.Option(..., "--type", "-t", help="Entity type to query"),
686
+ filter: list[str] = typer.Option(
687
+ [], "--filter", "-f", help="key=value filters (column or metadata)"
688
+ ),
689
+ since: str | None = typer.Option(
690
+ None,
691
+ "--since",
692
+ "-s",
693
+ help="Only results after this time: ISO timestamp or relative (12h, 30m, 2d)",
694
+ ),
695
+ mine: bool = typer.Option(False, "--mine", "-m", help="Only entities authored by me"),
696
+ has_attachments: bool = typer.Option(
697
+ False, "--has-attachments", help="Only Message entities that have file/image attachments"
698
+ ),
699
+ limit: int = typer.Option(50, "--limit", "-n", help="Maximum results"),
700
+ order_by: str = typer.Option(
701
+ "created_at",
702
+ "--order-by",
703
+ "-o",
704
+ help="Column to sort by (created_at, updated_at, last_accessed)",
705
+ ),
706
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
707
+ ) -> None:
708
+ """Query entities by type and filters."""
709
+ from agentgraph.cli_query import cmd_query
710
+
711
+ parsed_filters = dict(f.split("=", 1) for f in filter if "=" in f)
712
+ cmd_query(
713
+ entity_type=entity_type,
714
+ filters=parsed_filters,
715
+ limit=limit,
716
+ order_by=order_by,
717
+ since=since,
718
+ authored_by_me=mine,
719
+ has_attachments=has_attachments,
720
+ as_json=json,
721
+ )