Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions graphify/extractors/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,23 @@ def _norm_ident(name: str) -> str:
`table_nids` keys and lookups — node ids and display labels keep the
original text.
"""
if not name:
return name
# 2712: tree-sitter AST drops the opening bracket on syntax errors but retains the
# closing bracket (e.g. `dbo].[Customer]`). We must strip all leading/trailing
# delimiter characters regardless of whether they are balanced, otherwise references break.
parts = []
for part in name.split("."):
p = part.strip()
if len(p) >= 2 and ((p[0] == p[-1] and p[0] in ('"', "`"))
or (p[0] == "[" and p[-1] == "]")):
p = p[1:-1]
p = re.sub(r'^["`\[]', '', p)
p = re.sub(r'["`\]]$', '', p)
parts.append(p.lower())
return ".".join(parts)


_IDENT_PATTERN = r'(?:\"[^\"\n]+\"|\[[^\]\n]+\]|`[^`\n]+`|[\w$]+)(?:\s*\.\s*(?:\"[^\"\n]+\"|\[[^\]\n]+\]|`[^`\n]+`|[\w$]+))*'


def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
"""Extract tables, views, functions, and relationships from .sql files via tree-sitter."""
try:
Expand Down Expand Up @@ -72,6 +79,10 @@ def _read(n) -> str:
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")

def _obj_name(n) -> str | None:
text = _read(n)
m = re.search(rf"(?:CREATE|ALTER)\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:TABLE|VIEW|FUNCTION|PROCEDURE)\s+(?:IF\s+NOT\s+EXISTS\s+)?({_IDENT_PATTERN})", text, re.IGNORECASE)
if m:
return m.group(1)
for c in n.children:
if c.type == "object_reference":
return _read(c)
Expand Down Expand Up @@ -263,7 +274,7 @@ def walk(node) -> None:
for m in re.finditer(
r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\s+"
r"(?:IF\s+NOT\s+EXISTS\s+)?"
r"((?:\"[^\"\n]+\"|[\w$]+)(?:\s*\.\s*(?:\"[^\"\n]+\"|[\w$]+))*)",
rf"({_IDENT_PATTERN})",
text, re.IGNORECASE,
):
name = m.group(1)
Expand Down Expand Up @@ -379,6 +390,14 @@ def _collect_defined_names(node) -> None:

_collect_defined_names(root)

src_text = source.decode("utf-8", errors="replace")
if root.has_error:
for m in re.finditer(rf"CREATE\s+(?:TABLE|VIEW)\s+({_IDENT_PATTERN})", src_text, re.IGNORECASE):
name = m.group(1)
norm = _norm_ident(name)
if norm not in table_nids:
table_nids[norm] = _make_id(stem, name)

# Secondary bare-name aliases: a reference written without a schema
# (`REFERENCES users`) should resolve to a schema-qualified definition
# (`public.users`) when that is unambiguous. Never shadow an explicit
Expand All @@ -405,8 +424,7 @@ def _collect_defined_names(node) -> None:
# (e.g. Firebird COMPUTED BY columns push constraints out of the tree entirely).
# Snapshot after tree walk so we don't re-emit edges already captured above.
emitted = {(e["source"], e["target"]) for e in edges if e["relation"] == "references"}
src_text = source.decode("utf-8", errors="replace")
for m in re.finditer(r"CREATE\s+TABLE\s+([\w$]+)\s*\(", src_text, re.IGNORECASE):
for m in re.finditer(rf"CREATE\s+TABLE\s+({_IDENT_PATTERN})\s*\(", src_text, re.IGNORECASE):
tbl_name = m.group(1)
tbl_nid = table_nids.get(_norm_ident(tbl_name))
if tbl_nid is None:
Expand All @@ -415,7 +433,7 @@ def _collect_defined_names(node) -> None:
tail = src_text[m.start():]
end = re.search(r"(?:^|\n)(?:CREATE|SET\s+TERM|ALTER)\s", tail[1:], re.IGNORECASE)
block = tail[: end.start() + 1] if end else tail
for rm in re.finditer(r"\bREFERENCES\s+([\w$]+)", block, re.IGNORECASE):
for rm in re.finditer(rf"\bREFERENCES\s+({_IDENT_PATTERN})", block, re.IGNORECASE):
ref_name = rm.group(1)
ref_nid = table_nids.get(_norm_ident(ref_name)) or _ref_stub(ref_name)
if (tbl_nid, ref_nid) not in emitted:
Expand All @@ -441,10 +459,15 @@ def _collect_defined_names(node) -> None:
# observed drop shape leaves an ERROR node in the tree, so has_error loses
# nothing while protecting clean corpora (#2180 follow-up).
if root.has_error:
for m in re.finditer(rf"CREATE\s+(?:TABLE|VIEW)\s+({_IDENT_PATTERN})", src_text, re.IGNORECASE):
name = m.group(1)
m_line = src_text[: m.start()].count("\n") + 1
_add_node(_make_id(stem, name), name, m_line)

for m in re.finditer(
r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\s+"
r"(?:IF\s+NOT\s+EXISTS\s+)?"
r"((?:\"[^\"\n]+\"|[\w$]+)(?:\s*\.\s*(?:\"[^\"\n]+\"|[\w$]+))*)",
rf"({_IDENT_PATTERN})",
src_text, re.IGNORECASE,
):
fn_name = m.group(1)
Expand Down
33 changes: 25 additions & 8 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -1513,8 +1513,15 @@ def _select_graph(project_path) -> None:

# NOTE: no decorators here — the handlers below are plain coroutines,
# bound to the Server at the END of this function in a version-aware way:
# mcp 1.x exposes the @server.list_tools()/... decorator API, mcp 2.x
# replaced it with on_list_tools=/... constructor callbacks.
# - mcp 1.x exposes @server.list_tools()/call_tool()/... decorators.
# The SDK automatically wraps handler returns (str -> TextContent,
# list[TextContent] -> CallToolResult, etc).
# - mcp 2.x replaced decorators with on_list_tools=/on_call_tool=/...
# constructor callbacks using the (ctx, params) -> Result contract.
# Handlers must construct and return result objects directly.
# The version check uses hasattr(Server, "list_tools") to detect which
# API is available at runtime, allowing a single codebase to support both.
# See lines 1995 (1.x) and 2003 (2.x) for the registration branches.
async def list_tools() -> list[types.Tool]:
_tools = [
types.Tool(
Expand Down Expand Up @@ -1972,17 +1979,28 @@ async def read_resource(uri: AnyUrl) -> str:
return f"Could not generate questions: {exc}"
raise ValueError(f"Unknown resource: {uri_str}")

async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
async def call_tool(name: str, arguments: dict) -> types.CallToolResult:
# Both mcp 1.x and 2.x define CallToolResult, so isError=True
# can be set regardless of version. This unified approach avoids
# per-version branching in the implementation.
arguments = dict(arguments or {})
project_path = arguments.pop("project_path", None)
handler = _handlers.get(name)
if not handler:
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
return types.CallToolResult(
content=[types.TextContent(type="text", text=f"Unknown tool: {name}")],
isError=True,
)
try:
_select_graph(project_path) # bind G/communities to the target graph
return [types.TextContent(type="text", text=handler(arguments))]
return types.CallToolResult(
content=[types.TextContent(type="text", text=handler(arguments))]
)
except Exception as exc:
return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")]
return types.CallToolResult(
content=[types.TextContent(type="text", text=f"Error executing {name}: {exc}")],
isError=True,
)

if hasattr(Server, "list_tools"):
# mcp 1.x: decorator-based registration. The SDK wraps the raw returns
Expand All @@ -2000,8 +2018,7 @@ async def _on_list_tools(ctx, params) -> types.ListToolsResult:
return types.ListToolsResult(tools=await list_tools())

async def _on_call_tool(ctx, params) -> types.CallToolResult:
content = await call_tool(params.name, dict(params.arguments or {}))
return types.CallToolResult(content=content)
return await call_tool(params.name, dict(params.arguments or {}))

async def _on_list_resources(ctx, params) -> types.ListResourcesResult:
return types.ListResourcesResult(resources=await list_resources())
Expand Down
Loading