-
Notifications
You must be signed in to change notification settings - Fork 0
docs(skills): add graphistry-mcp skill #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dess890
wants to merge
5
commits into
main
Choose a base branch
from
feat/graphistry-mcp-skill
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ce97fb1
feat(skills): add graphistry-mcp skill
dess890 202484b
docs(changelog): record graphistry-mcp skill under Development
dess890 a4dbd18
docs(skills): correct graphistry-mcp against the shipped server
dess890 b48a69c
feat(evals): port per-skill evals into the journey harness
dess890 f9c2146
fix(skills): address review — collection refusals, README, eval checks
dess890 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,260 @@ | ||
| --- | ||
| name: graphistry-mcp | ||
| description: "Drive a live Graphistry visualization session from any MCP client. Covers connecting to the viz MCP endpoint, personal-key and JWT auth, session ownership, the tool surface, and GFQL sent as JSON over the wire. Use when an external agent must query or recolor the graph a user is currently looking at." | ||
| --- | ||
|
|
||
| # Graphistry MCP | ||
|
|
||
| ## Scope | ||
|
|
||
| Use this skill to operate a **live Graphistry visualization session** from an MCP client: inspect | ||
| its schema, run GFQL queries against it, and create colored collections the user sees update in | ||
| their browser. | ||
|
|
||
| ## Two servers share the name "graphistry mcp" | ||
|
|
||
| Pick the right one before writing any code. | ||
|
|
||
| | Server | Use it for | | ||
| |---|---| | ||
| | **Viz MCP** — `https://<graphistry-host>/mcp` | Reading and mutating the graph a user already has open. Session-scoped. **This skill.** | | ||
| | **PyGraphistry MCP** — `github.com/graphistry/graphistry-mcp` | Building and uploading new visualizations from scratch. Hub-scoped, unrelated to this skill. | | ||
|
|
||
| If the task starts from a graph the user is looking at, use the viz MCP. If it starts from a | ||
| DataFrame or a file, use the PyGraphistry MCP or the `pygraphistry` skill. | ||
|
|
||
| ## Connect | ||
|
|
||
| Streamable HTTP at `POST /mcp`. `tools/call` carries a credential on the `Authorization` header; | ||
| `initialize` and `tools/list` do not. | ||
|
|
||
| Two credentials work. Prefer a personal key: it does not expire, so a long-running agent will not | ||
| start returning `401` partway through a conversation the way a JWT does. | ||
|
|
||
| | Credential | Header | Expires | | ||
| |---|---|---| | ||
| | Personal key `key_id:key` | `Authorization: Bearer <key_id>:<key>` or `PersonalKey <key_id>:<key>` | no | | ||
| | Viewer JWT | `Authorization: Bearer <jwt>` | yes, about an hour | | ||
|
|
||
| Create a personal key on the Graphistry account page at `/users/personal/key/`. Both schemes are | ||
| accepted for it because most MCP clients only expose a single "Bearer token" field. | ||
|
|
||
| ```bash | ||
| curl -si https://<host>/mcp \ | ||
| -H "Authorization: Bearer $GRAPHISTRY_PERSONAL_KEY" \ | ||
| -H 'Content-Type: application/json' \ | ||
| -H 'Accept: application/json, text/event-stream' \ | ||
| -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ | ||
| "protocolVersion":"2024-11-05","capabilities":{}, | ||
| "clientInfo":{"name":"my-agent","version":"1.0"}}}' | ||
| ``` | ||
|
|
||
| `initialize` returns an `Mcp-Session-Id` response header. Send it back as the `mcp-session-id` | ||
| header on every subsequent request. This is the MCP transport session and is distinct from the | ||
| Graphistry viz `session_id` the tools take. If it expires the server answers `404` — reinitialize | ||
| and retry rather than treating the call as failed. | ||
|
|
||
| The server currently reports protocol version `2024-11-05`. | ||
|
|
||
| Client config for MCP-aware tools: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "graphistry": { | ||
| "type": "http", | ||
| "url": "https://<host>/mcp", | ||
| "headers": { "Authorization": "Bearer ${GRAPHISTRY_PERSONAL_KEY}" } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Session model | ||
|
|
||
| Tools other than `list_sessions` require a `session_id` identifying a live viz session. | ||
|
|
||
| - **A client cannot create a session.** A session exists because a user has the graph open in a | ||
| browser. Discover one with `list_sessions`. | ||
| - **Pass the session you were given.** `list_sessions` returns bare ids with nothing to tell them | ||
| apart, so when a user has two graphs open an agent that discovers one instead of using the one it | ||
| was handed will silently answer about the wrong graph. A client that knows which graph is on | ||
| screen should supply `session_id` rather than discover it. | ||
| - **Only the owner may mutate.** The credential's user must be the user whose browser holds that | ||
| session, or the collection tools return | ||
| `403 Forbidden: only the session owner may modify this session`. Reads may still succeed for a | ||
| non-owner who has access to the underlying dataset; without it they return | ||
| `403 Forbidden: caller cannot access this session`. `list_sessions` shows only the caller's own | ||
| sessions, so a shared service account will not see an end user's session. | ||
| - **A closed tab means no session.** With no live owner the call returns | ||
| `410 Session owner unavailable`. A `410` can also mean the owner lookup itself failed, so retry | ||
| once before concluding the graph is gone. | ||
| - **`list_sessions` can return `[]` while your session is alive.** It sees only the sessions owned | ||
| by the server process that happened to receive the request. Emptiness is not evidence of no | ||
| session — another reason to pass the `session_id` you were given. | ||
|
|
||
| Other statuses a client must tell apart: | ||
|
|
||
| | Status | Meaning | Retry? | | ||
| |---|---|---| | ||
| | `403 Talk2Graph is not enabled for this account` | credential is valid, the account lacks the entitlement | no — every `tools/call` will fail | | ||
| | `503 Entitlement check unavailable` | the access check itself failed | yes | | ||
| | `504 Tool call timeout` | the owning process did not answer in time | yes | | ||
| | `400 Missing mcp-session-id` | the header was absent | send the header; this is not the 404 case | | ||
| | `404 Unknown or expired mcp-session-id` | the transport session lapsed | reinitialize | | ||
|
|
||
| The transport session expires after 5 minutes idle, sliding on each use, so a slow agent should | ||
| expect a `404` and reinitialize rather than treat it as a failure. A revoked credential keeps | ||
| working for up to 60 seconds because verification is cached. | ||
|
|
||
| ## Tool surface | ||
|
|
||
| Read: | ||
|
|
||
| | Tool | Required | Optional | | ||
| |---|---|---| | ||
| | `list_sessions` | — | — | | ||
| | `get_session_info` | `session_id` | — | | ||
| | `query_graph` | `session_id`, `gfql_operations` | `output_type` (`shape` default, `nodes`, `edges`, `all`), `format` | | ||
| | `list_collections` | `session_id` | — | | ||
|
|
||
| Mutate (these change what the user sees): | ||
|
|
||
| | Tool | Required | Optional | | ||
| |---|---|---| | ||
| | `create_collection` | `session_id`, `name`, `gfql_operations`, and one of `node_color` / `palette` | the other of `node_color` / `palette`, `description` | | ||
| | `update_collection` | `session_id`, `collection_id` | `name`, `node_color`, `palette` | | ||
| | `delete_collection` | `session_id`, `collection_id` | — | | ||
| | `reorder_collections` | `session_id`, `order` | — | | ||
| | `reset_collections` | `session_id` | — | | ||
|
|
||
| `query_graph` returns at most 50 rows when the expression names columns and 20 when it does not, | ||
| projected to the id column plus the columns the expression referenced; the reported count is the | ||
| true total. A value list read from a truncated result cannot prove that a value is absent. | ||
|
|
||
| `create_collection` keeps at most 10 collections, evicting the oldest, replaces any existing | ||
| collection of the same name, and returns a cached result for an identical call repeated within | ||
| 30 seconds. `list_collections` reports only collections this MCP session created, never ones the | ||
| user made in the browser, and it is emptied when the user's socket reconnects. | ||
|
|
||
| `gfql_operations` is a **JSON-encoded string**, not a nested object. `order` lists collection ids | ||
| top-to-bottom; the first renders on top. For a solid color pass a hex `node_color`; use `palette` | ||
| only when a named palette is explicitly requested. On `update_collection`, omit a field to leave | ||
| it unchanged — do not pass `null`. | ||
|
|
||
| ## GFQL over the wire | ||
|
|
||
| Sent as a JSON string. The operation types you need are `Node`, `Edge`, `Call`, `Let`, and `Ref`. | ||
|
|
||
| ```json | ||
| [{"type": "Node", "filter_dict": {"category": "Malware"}}] | ||
| ``` | ||
|
|
||
| `Node` filters; a predicate object such as `{"type": "GT", "val": 10}` inside `filter_dict` | ||
| thresholds. `Edge` traverses and returns its matched relationship subgraph — it does not project a | ||
| single endpoint kind. Every row-pipeline step (`rows`, `group_by`, `order_by`, `limit`) is a | ||
| `Call`. `Let` with a `Ref` isolates one endpoint kind and is passed as a **top-level object, never | ||
| wrapped in an array**. `query_graph` also accepts one read-only Cypher string. | ||
|
|
||
| Full JSON for each form, including the aggregation pipeline and the `Let`/`Ref` projection: | ||
| `references/gfql-wire-format.md`. | ||
|
|
||
| ## Shapes that fail silently | ||
|
|
||
| These return `success: true` with wrong or empty results rather than erroring. **A zero-row answer | ||
| produced by any of them is not evidence of a real zero.** | ||
|
|
||
| - `filter_nodes_by_dict` / `filter_edges_by_dict` match exact stored values only. A predicate | ||
| object matches nothing — threshold with a `Node` operation instead. | ||
| - "External/public IP" and "internal/private IP" are derived properties, not requirements for | ||
| a dedicated status column. Inspect the live schema and values for the actual IP column first. | ||
| Unless the dataset documents another policy, treat RFC1918 IPv4 (`10/8`, `172.16/12`, | ||
| `192.168/16`), loopback, link-local, unspecified, multicast, and shared CGNAT (`100.64/10`) | ||
| as non-external; apply analogous IPv6 ULA/link-local/loopback/unspecified/multicast rules. | ||
| Documentation/reserved examples such as `198.51.100.0/24` and `203.0.113.0/24` are not | ||
| RFC1918 internal addresses, so do not silently discard them unless the dataset explicitly | ||
| defines them as non-external fixtures. Match the exact stored representation; do not apply | ||
| IPv4 rules to IPv6, host:port, URL, or CIDR strings without validating them. Use a positive | ||
| GFQL pattern where supported, or `IsIn` only from an exhaustive value set. A limited/sample | ||
| table cannot prove a complement; if the value set is truncated or GFQL cannot express the | ||
| classification, report that limitation instead of claiming a complete collection. | ||
| - Put both graph filters before `rows`. After a `rows` on the edge table a filter is dropped and | ||
| the aggregation reports unfiltered counts. | ||
| - `where_rows` accepts `expr` (a string like `"n > 10"`) and `filter_dict`. In `filter_dict` only | ||
| exact stored values match, so a predicate object there matches nothing; use `expr` to compare. | ||
| - `get_session_info` returns column names only, never stored values, so it can never establish | ||
| that a value is absent. | ||
|
|
||
| ## Errors you will actually see | ||
|
|
||
| These fail loudly with a specific message. Read it — it names the cause, so it is worth acting on | ||
| rather than rewriting the expression blindly. | ||
|
|
||
| - `rows` defaults to `table: "nodes"`. Grouping by an edge column without `{"table": "edges"}` | ||
| fails as a missing column. | ||
| - `Edge` requires `direction`, one of `forward`, `reverse`, `undirected`. `both` is invalid. | ||
| `create_collection` fills in `undirected` when it is absent, so an expression that validated | ||
| there can still fail in `query_graph` — set it explicitly. | ||
| - A bare `{"type": "rows"}`, `{"type": "group_by"}`, `{"type": "order_by"}`, or | ||
| `{"type": "limit"}` is invalid. Wrap every one as a `Call`. | ||
| - Predicate `type` names are case-sensitive: `GT` works, `gt` fails with an opaque error. | ||
| - `create_collection` takes JSON GFQL only. Cypher is accepted by `query_graph` alone. | ||
| - `create_collection` refuses an expression whose result stops identifying graph nodes — one that | ||
| groups rows, projects the id away, renames or drops it, or reads the edge table. Ordering by a | ||
| community column is refused separately, because that ranks by community id rather than size. Both | ||
| refusals name the repair: select the nodes themselves, usually a `Node` filter, or report the | ||
| sizes with `query_graph` instead. Act on it rather than reporting failure. | ||
| - If a collection is refused because the service could **not evaluate** the expression, that is a | ||
| different case: report the failure. Do not reword the filter and retry — the expression was not | ||
| the problem. | ||
|
|
||
| ## Workflow | ||
|
|
||
| 1. `list_sessions` to find the live session. | ||
| 2. `get_session_info` for the schema. Choose columns from the list it returns, most useful first. | ||
| If a line ends with `(+K more not listed)` the list is truncated and the count in parentheses is | ||
| the real total — a column's absence from the list is not evidence it does not exist, so ask | ||
| rather than invent a name. `unavailable (schema could not be read)` means the schema was never | ||
| read; treat that as a failed inspection, not as a graph without columns. Being listed is | ||
| necessary but not sufficient: some columns are computed for rendering and cannot be filtered. | ||
| 3. **Inspect stored values before filtering on them.** Natural-language nouns from the user are | ||
| unobserved candidates, never literals. Aggregate the distinct values of the relevant column | ||
| first and compare case-sensitively — `Hashtag` will not match a search for `hashtag`. | ||
| 4. Validate the expression with `query_graph` before mutating. | ||
| 5. `create_collection` reusing that exact validated JSON unchanged, and report the match count it | ||
| returns. `create_collection` accepts a narrower set of shapes than `query_graph` validates: a | ||
| chain that aggregates or leaves the node axis will pass `query_graph` and still be refused here. | ||
| When that happens the refusal names the repair — use it. The count is not guaranteed — when | ||
| the preflight cannot produce one the collection is still applied and the reply says so. Report "applied, count unavailable" rather than inventing | ||
| a number or omitting the outcome. | ||
|
|
||
| Stop and report a GFQL error rather than retrying with an invented operation name. A failed query | ||
| is not an inspection result. | ||
|
|
||
| Zero rows never establishes absence when any value in the expression came from the user's wording | ||
| rather than from a tool result. Confirm every value was tool-observed before reporting an empty | ||
| result. | ||
|
|
||
| ## Decision Rules | ||
|
|
||
| - Use this skill when the graph is already open and the task is to query or recolor it. | ||
| - Route to `pygraphistry-gfql` for GFQL written as Python AST objects (`n()`, `e_forward()`) or | ||
| for `g.gfql()` in a notebook. This skill covers the JSON wire format only. | ||
| - Route to `pygraphistry` to build or upload a graph. | ||
| - Route to `graphistry-rest-api` for auth, upload, and dataset endpoints. | ||
|
|
||
| ## Safety Rules | ||
|
|
||
| - Keep credentials in environment variables. Never hardcode a username, password, personal key, or | ||
| JWT. | ||
| - Send the credential in the `Authorization` header. Never put one in a URL query parameter. No | ||
| documented tool parameter takes a credential, so it stays out of the model's context — never | ||
| add one to `arguments` yourself. | ||
| - Treat mutation tools as user-visible: only create, update, or delete a collection when the user | ||
| asked to change the visualization. | ||
|
|
||
| ## References | ||
|
|
||
| - Graphistry Hub docs: https://hub.graphistry.com/docs/ | ||
| - MCP specification: https://modelcontextprotocol.io/specification/ | ||
| - REST auth docs: https://hub.graphistry.com/docs/api/1/rest/auth/ |
120 changes: 120 additions & 0 deletions
120
.agents/skills/graphistry-mcp/references/gfql-wire-format.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| # GFQL wire format | ||
|
|
||
| Full JSON forms for `gfql_operations`. The decision-critical rules live in `SKILL.md`; this file | ||
| holds the complete examples. | ||
|
|
||
| `gfql_operations` is always a **JSON-encoded string**, never a nested object. The operation types | ||
| you need are `Node`, `Edge`, `Call`, `Let`, and `Ref`. | ||
|
|
||
| ## Node | ||
|
|
||
| Filters nodes. Equality against a stored value: | ||
|
|
||
| ```json | ||
| [{"type": "Node", "filter_dict": {"category": "Malware"}}] | ||
| ``` | ||
|
|
||
| Threshold with a predicate object. The predicate goes inside `filter_dict`, keyed by column: | ||
|
|
||
| ```json | ||
| [{"type": "Node", "filter_dict": {"event_count": {"type": "GT", "val": 10}}}] | ||
| ``` | ||
|
|
||
| Predicate `type` names are **case-sensitive** — `GT` works, `gt` fails with an opaque error — and | ||
| the key carrying the operand differs by family: | ||
|
|
||
| | Family | Types | Operand key | | ||
| |---|---|---| | ||
| | comparison | `GT` `LT` `GE` `LE` `EQ` `NE` | `val` | | ||
| | range | `Between` | `lower`, `upper` | | ||
| | set | `IsIn` | `options` | | ||
| | string | `Contains` `Startswith` `Endswith` `Match` `Fullmatch` | `pat` | | ||
| | null | `IsNA` `NotNA` `IsNull` `NotNull` | none | | ||
| | shape | `IsNumeric` `IsAlpha` `Duplicated` and similar | none | | ||
|
|
||
| The server normalizes casing and key names only for `filter_dict` on a top-level operation array. | ||
| Inside `edge_match`, inside a `Let`, and anywhere in `create_collection`, write the exact form. | ||
|
|
||
| An **empty** `Node` operation (`{"type": "Node"}`) accumulates both endpoints of a preceding | ||
| `Edge` rather than filtering. Use it when the request names two node kinds joined by "and"/"or". | ||
|
|
||
| ## Edge | ||
|
|
||
| Traverses. Takes `direction`, `hops`, and `edge_match`: | ||
|
|
||
| ```json | ||
| [{"type": "Edge", "direction": "undirected", "hops": 1, | ||
| "edge_match": {"rel_type": "mentioned"}}] | ||
| ``` | ||
|
|
||
| `direction` must be `forward`, `reverse`, or `undirected`. `both` is invalid and fails. | ||
|
|
||
| `Edge` returns its matched relationship subgraph — it does **not** project a single endpoint kind. | ||
| Both endpoints come back. To isolate one kind, use `Let`/`Ref` below. | ||
|
|
||
| ## Call | ||
|
|
||
| Every row-pipeline step is a `Call`. A bare `{"type": "rows"}`, `{"type": "group_by"}`, | ||
| `{"type": "order_by"}`, or `{"type": "limit"}` is invalid. | ||
|
|
||
| ```json | ||
| [{"type":"Call","function":"rows","params":{"table":"edges"}}, | ||
| {"type":"Call","function":"group_by","params":{"keys":["<edge_column>"],"aggregations":[["n","count"]]}}, | ||
| {"type":"Call","function":"order_by","params":{"keys":[["n","desc"]]}}, | ||
| {"type":"Call","function":"limit","params":{"value":20}}] | ||
| ``` | ||
|
|
||
| `rows` defaults to `table: "nodes"`. Set it to the table holding the columns you group by, or the | ||
| query fails on a missing column. | ||
|
|
||
| This chain is a `query_graph` answer, not a collection. It aggregates away the node id, so | ||
| `create_collection` refuses it — color the nodes with a separate `Node` filter built from the | ||
| values it returns. | ||
|
|
||
| `aggregations` is a list of `[alias, function, column]` triples. Only `count` may be the | ||
| 2-element `[alias, "count"]`; every other function — `count_distinct`, `sum`, `min`, `max`, `avg`, | ||
| `mean`, `collect`, `collect_distinct` — requires the column it aggregates, and `"*"` is rejected | ||
| for them. Counting rows where the question asked for distinct values returns a plausible table | ||
| answering a different question. | ||
|
|
||
| `order_by` `keys` is a list of `[column, direction]` pairs; the direction is mandatory and must be | ||
| `asc` or `desc`. | ||
|
|
||
| Put graph filters (`filter_nodes_by_dict`, `filter_edges_by_dict`) before `rows`. After a `rows` | ||
| on the edge table the filter is dropped and the aggregation reports unfiltered counts. | ||
|
|
||
| `where_rows` accepts `expr` (a string like `"n > 10"`) and `filter_dict`. In `filter_dict` only | ||
| exact stored values match, so a predicate object there matches nothing; use `expr` to compare. | ||
|
|
||
| ## Let and Ref | ||
|
|
||
| Isolate one endpoint kind: bind the edge subgraph, then filter that binding through the `Ref`'s | ||
| `chain`. Pass as a **top-level object, never wrapped in an array**: | ||
|
|
||
| ```json | ||
| {"type":"Let","bindings":{ | ||
| "connections":{"type":"Edge","direction":"undirected","hops":1,"edge_match":{"<edge_column>":"<observed_value>"}}, | ||
| "requested_endpoints":{"type":"Ref","ref":"connections","chain":[{"type":"Node","filter_dict":{"<node_kind_column>":"<observed_kind>"}}]}}} | ||
| ``` | ||
|
|
||
| Use this when the request names one endpoint kind modified by a relationship — "accounts connected | ||
| by transfers" wants accounts, not the transfers and their far side. | ||
|
|
||
| When the request instead names two kinds joined by "and"/"or", use an `Edge` followed by an empty | ||
| `Node` so both sides accumulate. | ||
|
|
||
| ## Cypher | ||
|
|
||
| `query_graph` accepts one whole read-only Cypher string in place of the operation list. One | ||
| statement per call; no writes. `create_collection` does not — it takes JSON GFQL only. | ||
|
|
||
| ## Output | ||
|
|
||
| `output_type` selects what comes back: `shape` (default, counts only), `nodes`, `edges`, or `all`. | ||
| Use `nodes` or `edges` to read actual values — `shape` on an aggregation returns the number of | ||
| aggregation rows, not the values inside them, which is an easy way to misread a result. | ||
|
|
||
| Rows are capped at 50 when the expression names columns and 20 when it does not, and are projected | ||
| to the id column plus the columns the expression referenced. The reported count is the true total, | ||
| so a group_by with more distinct values than the cap is truncated: a value list read from it cannot | ||
| prove that some value is absent. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok to have this, though unsure why we do, seems better to advocate cypher as LLM's understand it better