Skip to content

feat: add query_semantic_graph over the semantic graph views - #48

Open
killme2008 wants to merge 9 commits into
mainfrom
feat/semantic-graph-tool
Open

feat: add query_semantic_graph over the semantic graph views#48
killme2008 wants to merge 9 commits into
mainfrom
feat/semantic-graph-tool

Conversation

@killme2008

@killme2008 killme2008 commented Sep 5, 2026

Copy link
Copy Markdown
Member

Adds query_semantic_graph, which reads greptime_private.semantic_entities and greptime_private.semantic_relationships.

What it does

One tool with three views: summary, entities, relationships.

The time window is required. It is half-open, [start_time, end_time), over observed_at, which is the 60-second bucket an observation was recorded in. Timestamps are RFC3339; one without an offset is read as UTC. Rows are aggregated across the buckets in the window, and the result echoes back the window and the limit it used.

The views only exist in GreptimeDB 1.3. At startup the server checks that both are present, carry the columns it reads, and can be read by the connected account, and drops the tool from the list when the answer is conclusive. On 1.2 it is not offered and the reason is logged. The check uses its own connection with a 10 second timeout, since it runs before the server can serve anything.

Output

Captured on 1.3. One table declares service and k8s.pod entities. Four declared edges: frontend -> checkout and checkout -> payment as calls, checkout -> pod-a and pod-a -> node-1 as runs_on.

view="summary"

{
  "view": "summary",
  "window": {"start": "2026-09-09T10:25:42+00:00", "end": "2026-09-09T11:25:42+00:00"},
  "entity_types": [
    {"type": "k8s.pod", "count": 1},
    {"type": "service", "count": 1}
  ],
  "relationship_types": [
    {"type": "calls", "count": 2,
     "endpoints": [{"source": "service", "destination": "service", "count": 2}]},
    {"type": "runs_on", "count": 2,
     "endpoints": [{"source": "k8s.pod", "destination": "k8s.node", "count": 1},
                   {"source": "service", "destination": "k8s.pod", "count": 1}]}
  ]
}

Endpoint types come back as pairs. Reduced to a set of sources and a set of destinations, service -> k8s.pod and k8s.pod -> k8s.node would read as service -> k8s.node.

view="relationships" and view="entities" return items alongside status, applied_filters, limit, item_count and complete:

{"view": "relationships", "status": "ok", "applied_filters": {},
 "limit": 100, "item_count": 4, "complete": true}

A relationship item:

{
  "src_type": "service", "src_id": "checkout",
  "dst_type": "service", "dst_id": "payment",
  "rel_type": "calls", "provenance": "declared", "confidence": 1.0,
  "request_count": 100, "unmatched_count": null, "error_count": 45,
  "duration_sum": 12.5, "duration_count": 100, "duration_max": null,
  "first_seen": "2026-09-09 10:55:29.902000",
  "last_seen": "2026-09-09 10:55:29.902000",
  "fresh_until": "2026-09-09 11:25:42"
}

An entity item:

{
  "entity_type": "service", "entity_id": "checkout",
  "entity_id_attrs": {"service_name": "checkout"},
  "scope": "prod", "descriptive": null,
  "source_tables": ["public.svc_latency"],
  "first_seen": "2026-09-09 10:55:00", "last_seen": "2026-09-09 10:55:00",
  "fresh_until": "2026-09-09 10:56:00"
}

Ordering, same window:

Input Order
view="relationships" calls checkout->payment, calls frontend->checkout, runs_on pod-a->node-1, runs_on checkout->pod-a
view="relationships", rel_type="calls" checkout->payment (45 errors), frontend->checkout (7 errors)

Other cases:

Input Result
rel_type="calls", src_id="checkout-svc" status: "no_match", items: [], and guidance.next_query with the id dropped and the type filter and window kept
more rows than limit complete: false plus guidance listing the filters that would narrow it
view="paths" ToolError: Invalid view: paths. Must be one of: summary, entities, relationships
view="entities", rel_type="calls" ToolError: Filter rel_type does not apply to view=entities. Available: entity_type, entity_id, scope
start_time="yesterday" ToolError: Invalid start_time: yesterday. Use an RFC3339 timestamp such as 2026-09-05T07:00:00Z
database read fails ToolError
on 1.2 the tool is not in the list

Things to check when reviewing

One row per edge per confidence. The database reports confidence 1.0 for a bucket whose client and server spans paired and 0.5 for one where only the client was seen, and it switches request_count, error_count and the durations to whichever population that bucket describes — a pair is timed by the server span, an unmatched client by its own. Summing across both would add two different measurements, so confidence is in the group key. An edge seen both ways returns two rows.

request_count is not always a count of paired calls. Where a bucket paired nothing, the database reports the unmatched clients there instead. unmatched_count is the client spans with no paired server span. Durations are in seconds.

Window bounds carry their offset. Without one the database reads them in the session time zone, and this server takes --timezone. On 1.3, an edge at 07:05Z queried over [07:00Z, 07:10Z) returned one row under a +00:00 session and none under +08:00.

Only the four JSON columns are decoded. Decoding every string turned entity_id "123" into a number and "null" into nothing.

Masking. A returned field whose own name matches a pattern is hidden, and attribute maps are then masked by the names inside them. entities also hides entity_id when a masked attribute helped build it. relationships cannot do that — its view carries no attribute names — so the same value can still appear there as src_id or dst_id unless a pattern names those columns.

entities returns one row per set of attributes, so an entity whose attributes changed inside the window appears more than once and item_count is not an entity count. first_seen and last_seen bound the observation inside the window, not the entity's lifetime.

Not included. attributes is left off the aggregated relationship rows: it varies per observation, so grouping by it splits an edge and breaks the totals. There is no path traversal; multi-hop is still a self-join through execute_sql. There is no cursor for paging past limit.

Not covered by tests. An edge observed at both confidences, end to end. Declared edges cannot express it, because the computed view keeps one revision per identity, so a second row for the same edge replaces the first whatever its scope or bucket. That needs trace-derived pairing, which the integration suite does not produce.

This shape is not the one the earlier experiments measured: summary is new and bucket=window was dropped. It should be re-validated rather than inheriting those results.

Verification

255 unit tests. Integration against freshly started instances: 26 passed on 1.3.0-alpha.1, and 21 passed with 5 skipped on 1.2.0-beta.1, where the assertion that the tool is absent runs on both.

@killme2008
killme2008 marked this pull request as draft September 5, 2026 07:21
@killme2008

Copy link
Copy Markdown
Member Author

All six fixed and verified against 1.3. Both criticals reproduced first.

1. Identifier decoding. Confirmed: "123"123, "null"None, "true"True, and "1e5"100000.0 as well. Only entity_id_attrs, descriptive, source_tables and attributes are decoded now; everything else is returned verbatim. Regression tests cover the JSON-literal-shaped ids, and an integration test feeds a returned src_id straight back into a filtered query and asserts it matches.

2. Window offset. Reproduced exactly as you measured — edge at 07:05Z, window [07:00Z, 07:10Z):

session +00:00  strftime (offset dropped) -> 1 rows
session +00:00  isoformat (offset kept)   -> 1 rows
session +08:00  strftime (offset dropped) -> 0 rows
session +08:00  isoformat (offset kept)   -> 1 rows

Bounds now carry +00:00. This mattered more than it looks: the server already takes --timezone, so any non-UTC deployment was querying a shifted window. Added an integration test that runs the suite's window through an Asia/Shanghai session, plus a unit test asserting the bound params keep their offset.

3. next_query. Now carries the window. The integration test was indeed pinning the broken shape — it now runs the suggested query and asserts the retry succeeds, rather than asserting its literal contents.

4. Capability/SQL mismatch. Took the simpler branch you suggested: 1.3 is the floor, so every column actually read is required, and the SQL is fixed instead of capability-shaped. That removes all three sub-issues at once. Worth noting my test_relationships_omit_red_columns_the_view_lacks passed while generating MAX(confidence), , MIN(...) — the fake cursor never parsed the SQL, so the test asserted the columns were absent without noticing the statement was malformed. Replaced with a test that a view missing a RED column is classified incompatible_schema.

5. Input schema. view is a Literal, so it reaches the schema as an enum; filters and limit use pydantic Field with descriptions and bounds. Verified against the generated schema:

"view":  {"enum": ["summary","entities","relationships"], "description": "...", "type": "string"}
"limit": {"minimum": 1, "maximum": 500, "default": 100, "description": "..."}

Which filters belong to which view is now in each filter's description rather than only in a runtime error.

6. Description. Rewritten against the shipped docs, not memory. observed_at is the observation bucket; confidence 1.0 covers paired and declared edges; the trace-context claim is gone, since an unpaired client span still produces a virtual-node edge at 0.5; unmatched_count is documented as what separates a callee that stopped answering from a caller that stopped asking. I also avoided implying confidence only takes two values, since an agent edge is not bound to either.

Endpoint pairs. Fixed — summary returns endpoints: [{source, destination, count}] per relationship type. The old two-set shape did lose combinations exactly as you describe.

Divergence from the bench-validated tool, stated explicitly

You are right that this shape is not the one the experiments validated, and the PR should say so rather than let the cited results imply coverage they do not have. What differs:

  • Added summary. No experimental support. The reasoning is the case-012 observation — plenty of runs_on visible, rel_type=runs_on still never queried — which says the problem is hypothesis selection, and a summary is a cheap way to put the graph's shape in front of the model. That is a hypothesis, not a result.
  • Dropped bucket=window. Deliberate. The aggregate is the main path and per-bucket rows were the confusion §3.1 describes. It means an edge that appears, changes or disappears inside the window is not visible as a timeline here; execute_sql still is.
  • attributes not returned on the aggregated view: it varies per observation, so grouping by it splits an edge and breaks the RED totals, and aggregating it presents one bucket's value as the edge's. Restored descriptive and fresh_until, which have no such problem.

If the graph tool is measured, it should be re-validated in the harness rather than inheriting the earlier numbers.

Verification

  • 238 unit tests.
  • Integration, both versions, on freshly started instances and run twice to confirm the fixture is idempotent: 1.3.0-alpha.1 26 passed; 1.2.0-beta.1 21 passed, 5 skipped with the tool-withdrawal assertion real on both legs.

@killme2008
killme2008 force-pushed the feat/semantic-table-search branch from 0539264 to 5cfddb7 Compare September 7, 2026 07:03
@killme2008
killme2008 force-pushed the feat/semantic-graph-tool branch 2 times, most recently from 5de036e to 5b300c6 Compare September 7, 2026 07:43
@killme2008
killme2008 force-pushed the feat/semantic-graph-tool branch 4 times, most recently from d47dfa7 to 9603d20 Compare September 7, 2026 11:07
One tool with three views. `summary` reports the entity types, the
relationship types, and the endpoint types each relationship connects, so a
caller learns the graph's shape without paging through edges to find out --
reading an unfiltered dump is both a round trip and an invitation to read the
result as a service call graph.

Only `calls` edges carry request, error, and duration counts, so an unfiltered
result is ordered by relationship type and endpoint. Ordering a mixed result by
RED would sort every other relationship type below a NULL. Passing
rel_type=calls opts into the RED ordering.

The window is required and half-open over `observed_at`, and the result echoes
the window it used. GreptimeDB rejects an unbounded lower bound and otherwise
defaults to the last hour, which silently answers a different question than the
caller asked.

A zero result keeps `items: []` and adds `status`, `applied_filters` and
guidance whose next query drops the identifier but keeps the type filters,
since an unfiltered read of a large graph is the failure this tool avoids.

The views only exist in GreptimeDB 1.3. Registration happens at import, before
a connection exists, so lifespan probes -- both views present, carrying the
columns read here, and readable by this account -- and withdraws the tool when
the answer is conclusive. An unreachable database is not conclusive and leaves
the tool in place.

Integration coverage seeds declared edges rather than OTLP traces, which gives
the graph one RED-bearing relationship type and one without.
Two defects that produced wrong results.

Every returned string was run through `json.loads`, so an identifier that
looked like a JSON scalar changed type: entity_id "123" came back as the
number 123, "true" as a boolean, and "null" as nothing at all. A caller cannot
feed those back in, and "null" is unrecoverable. Only the four JSON columns are
decoded now.

Window bounds were formatted without their offset, so the database read them in
the session time zone. Measured on 1.3 with an edge at 07:05Z and a window of
[07:00Z, 07:10Z): one row under a +00:00 session, none under +08:00. Since this
server takes --timezone, that silently answered a different question. Bounds
now carry +00:00, and an integration test runs the suite's window through an
Asia/Shanghai session.

Four contract defects:

- `next_query` in a no-match result omitted the required window, so following
  it failed on a missing argument. It now carries the window, and the
  integration test runs the suggested query instead of pinning its shape.
- Capability checks disagreed with the SQL: `scope` was selected and filterable
  without being required, an absent RED column produced `MAX(confidence), ,
  MIN(...)`, and rel_type=calls ordered by columns the probe allowed to be
  missing. GreptimeDB 1.3 is the floor, so every column read is now required
  and the SQL is fixed rather than capability-shaped.
- The input schema carried no argument semantics: the SDK drops a bare string
  in `Annotated`. view is a Literal, so it reaches the schema as an enum, and
  the filters and limit use pydantic Field with descriptions and bounds.
- The description misstated the contract. observed_at is the observation
  bucket, not the client side of a call; confidence 1.0 also covers declared
  edges; an unpaired client span still produces a virtual-node edge, so the
  trace-context claim was too strong; unmatched_count was undocumented despite
  being what separates a callee that stopped answering from a caller that
  stopped asking.

summary now reports endpoint pairs. Two sets lose which combinations exist:
service->pod and pod->node read as service->node. Entities also return
descriptive and freshness.
@killme2008
killme2008 force-pushed the feat/semantic-graph-tool branch from 9603d20 to 58a3535 Compare September 7, 2026 11:56
@killme2008
killme2008 changed the base branch from feat/semantic-table-search to main September 7, 2026 11:56
@killme2008 killme2008 closed this Sep 7, 2026
@killme2008 killme2008 reopened this Sep 7, 2026
…im the description

Three review findings and five API notes.

The graph tool serialized rows directly, so `mask_enabled` and `mask_patterns`
did not apply to it: an attribute map could publish what the same value would
have had masked through execute_sql. Attribute values whose name matches a
sensitive pattern are masked now, in `entity_id_attrs` and `descriptive`, and
`entity_id` with them when it was assembled from one -- it is those values
joined, so leaving it would undo the masking.

`MAX(confidence)` flattened two different measurements. The database reports
1.0 for a bucket whose spans paired and 0.5 for one that saw only clients, and
`real_wins` switches request_count, error_count and the durations to whichever
population that bucket describes: a pair timed by the server span, an unmatched
client by its own. Summing across both added those populations together and the
maximum hid that it had happened. `confidence` is in the group key now, so such
an edge returns one row per population.

The description asserted that request_count counts paired calls, which is only
true of a bucket that paired; where none did, the database reports the
unmatched clients there. It also read a fall in those counts as the callee
having stopped answering or the caller having stopped asking, which sampling
and missing instrumentation produce equally. Both are gone, durations are
stated in seconds, and the entities view now says it returns one row per set of
attributes so item_count is not an entity count, and that first_seen and
last_seen bound the observation inside the window rather than the entity's
lifetime. `Start with view=summary` became a condition rather than an order,
and the RCA guidance is out.

A truncated result now carries the effective limit and which filters would
narrow it, since there is no cursor and an unfiltered result ordered by type
can drop later types entirely. A failed query raises instead of returning prose,
so `status=no_match` only ever means the query ran and matched nothing.

Not covered: an edge observed at both confidences end to end. Declared edges
cannot express it -- the computed view keeps one revision per identity, so a
second row for the same edge replaces the first whatever its scope or bucket.
That needs trace-derived pairing, which the integration suite does not produce.
Comment thread tests/test_graph.py Fixed
…ide them

The previous change masked the names inside attribute maps and stopped there,
so a returned field was never checked against the patterns itself:
`mask_patterns=descriptive` left `descriptive` fully readable, and a pattern
naming `entity_id`, `src_id` or `dst_id` did nothing. A field whose own name
matches is hidden first now, exactly as the column would be through
execute_sql; maps that survive that are then masked by the names inside them.

Hiding `entity_id` when a masked attribute helped build it does not extend to
`relationships`: its view carries no attribute names, so the same value can
still appear there as `src_id` or `dst_id` unless a pattern names those
columns. The previous message implied those values were hidden everywhere.
They are not, and the tool description now says so.

The truncation hint claimed ordering by type and endpoint, which is wrong under
rel_type=calls; it states that later rows are missing and lists what to narrow
by.
Whether to hide `entity_id` was read back off the already-masked row, so a
pattern that hid the whole attribute map left nothing that looked like one and
the id went out in the clear. Configuring `mask_patterns=entity_id_attrs`
therefore re-exposed a value that the default rules had hidden: adding a rule
made masking weaker.

The decision is taken from the original row now, and a map hidden in full also
hides the id built from it. A regression test asserts the property directly --
across pattern sets, the id stays hidden and the sensitive value never appears
in the output.
`GraphCapability` carried the column set of each view, which nothing read once
the queries stopped being capability-shaped; the probe validates the columns
and reports availability. `summary` returned entity_count and
relationship_count, both sums of the arrays beside them, and a `complete` that
was always true. Every response repeated `bounds`, `time_field` and
`observation_bucket_seconds`, which are facts about the tool rather than about
the result and are already in its description.

Tests lost four duplicates: two that drove the same incompatible-schema branch
with a different column, two inputs of one decoding rule, a separate assertion
that the no-match retry runs, and two spellings of "the window parses to UTC".
…time

A probe that could not run was reported as a result. If startup left the
capability undecided and the retry inside a call then failed to read, the tool
returned status=unavailable with reason=error and the audit recorded a success,
while the identical connection failure one step later, in the query itself,
raised. It raises now; only a conclusive answer -- the view being absent,
rejected, or the wrong shape -- is still an answer.

The startup probe also had no time bound. It runs before the server can serve
anything, over a connection whose driver defaults leave both timeouts unset, so
a database that accepted the connection and then went quiet would hold
initialisation open and take every other tool with it. Wrapping the thread
would not have helped, since the blocking read would continue behind it; the
probe opens its own connection with connection_timeout and read_timeout
instead. Measured against a socket that accepts and never answers: startup
completes in 5.0s and keeps the tool, the timeout being inconclusive.

Both are driven from the tool entry point in the tests, not from _classify.
@killme2008
killme2008 marked this pull request as ready for review September 9, 2026 10:53
Five seconds is tight for a cold graph derivation on a busy instance, and the
cost of waiting longer is only paid when the database has stopped answering.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are documented response-shape/status classification mismatches (especially for summary and non-available capability cases) that can break clients relying on the PR’s stated contract.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adds a new MCP tool, query_semantic_graph, to query GreptimeDB 1.3’s semantic graph views (greptime_private.semantic_entities / semantic_relationships) over a required half-open time window and returns results in summary, entities, and relationships views, with unit + integration coverage.

Changes:

  • Introduces greptimedb_mcp_server.graph implementing window parsing, capability probing/negotiation, masking, and query execution for graph entities/relationships.
  • Registers query_semantic_graph in the server, including startup probing to withdraw the tool when the graph is conclusively unavailable.
  • Adds unit and integration tests (including seeding declared edges) and documents the new tool in the README.
File summaries
File Description
tests/test_graph.py Adds unit tests for window parsing, ordering, masking, probe classification, and response-guidance behaviors.
tests/integration/test_e2e.py Adds end-to-end integration coverage for graph tool availability, window semantics, ordering, and no-match guidance.
tests/integration/conftest.py Seeds declared graph edges for integration tests and tracks whether the graph is present/seeded.
src/greptimedb_mcp_server/server.py Registers query_semantic_graph tool and performs startup-time capability probing to withdraw the tool when unusable.
src/greptimedb_mcp_server/graph.py Implements graph probing, time-window parsing, SQL queries, ordering, masking, and result envelope construction.
README.md Documents the new query_semantic_graph tool and its GreptimeDB 1.3 dependency and window semantics.
Review details

Suppressed comments (1)

tests/integration/conftest.py:162

  • _seed_declared_edges() claims it returns False only when the graph does not exist, but it catches any mysql.connector.Error (including permission/validation failures). This docstring should reflect that it returns False for any insert failure, not just a missing table.
def _seed_declared_edges(cursor) -> bool:
    """Insert declared edges, reporting False when the graph does not exist."""
    columns = (
  • Files reviewed: 6/6 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/integration/conftest.py
Comment thread src/greptimedb_mcp_server/graph.py
Comment thread src/greptimedb_mcp_server/graph.py Outdated
Comment thread src/greptimedb_mcp_server/server.py Outdated
…ardown

`summary` carried no `status` when it succeeded but was given one when the
graph could not be read, and that unavailable response dropped `window`,
`applied_filters` and `limit` from the item views. Each view now answers with
its own shape whether the read happened or not: `view`, `status` and `window`
always, plus the empty type lists for summary or the empty item envelope for
the others.

The integration teardown deleted seeded edges by src_id and dst_id alone.
`semantic_relationships_declared` is shared and this suite does not create it,
so the delete now matches the endpoint types, the relationship type and the
provenance as well.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants