Skip to content

Commit 1d81562

Browse files
feat(mcp): Add a tool that can validate the syntax of a DQL query (#9465)
**Description** This PR adds the `validate_query_syntax` tool. NOTE: the tool does not validate mutations. Long story, but the parser used removed its ability to parse mutation blocks way back. **Checklist** - [x] Code compiles correctly and linting passes locally - [ ] For all _code_ changes, an entry added to the `CHANGELOG.md` file describing and linking to this PR - [x] Tests added for new functionality, or regression tests for bug fixes added as applicable --------- Co-authored-by: mattthew <matthew.mcneely@gmail.com>
1 parent 957e9e4 commit 1d81562

File tree

3 files changed

+129
-15
lines changed

3 files changed

+129
-15
lines changed

dgraph/cmd/mcp/mcp_server.go

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111

1212
"github.com/dgraph-io/dgo/v250"
1313
"github.com/dgraph-io/dgo/v250/protos/api"
14+
15+
"github.com/hypermodeinc/dgraph/v25/dql"
1416
"github.com/hypermodeinc/dgraph/v25/x"
1517

1618
"github.com/golang/glog"
@@ -72,14 +74,68 @@ func NewMCPServer(connectionString string, readOnly bool) (*server.MCPServer, er
7274
}),
7375
)
7476

77+
validateQuerySyntaxTool := mcp.NewTool("validate_query_syntax",
78+
mcp.WithDescription("Check if a Dgraph DQL Query is valid"),
79+
mcp.WithString("query",
80+
mcp.Required(),
81+
mcp.Description("The query to validate"),
82+
),
83+
mcp.WithString("variables",
84+
mcp.Description("The variables to be used in the query. Should be in JSON format to be unmarshalled into map[string]string"),
85+
),
86+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
87+
ReadOnlyHint: &True,
88+
DestructiveHint: &False,
89+
IdempotentHint: &True,
90+
OpenWorldHint: &False,
91+
}),
92+
)
93+
94+
s.AddTool(validateQuerySyntaxTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
95+
args := request.GetArguments()
96+
if args == nil {
97+
return mcp.NewToolResultError("Query must be present"), nil
98+
}
99+
queryArg, ok := args["query"]
100+
if !ok || queryArg == nil {
101+
return mcp.NewToolResultError("Query must be present"), nil
102+
}
103+
104+
op, ok := queryArg.(string)
105+
if !ok {
106+
return mcp.NewToolResultError("Query must be a string"), nil
107+
}
108+
109+
var variablesMap map[string]string
110+
variablesArg, ok := args["variables"]
111+
if ok && variablesArg != nil {
112+
variables, ok := variablesArg.(string)
113+
if !ok {
114+
return mcp.NewToolResultError("Variables must be a string"), nil
115+
}
116+
if err := json.Unmarshal([]byte(variables), &variablesMap); err != nil {
117+
return mcp.NewToolResultErrorFromErr("Error unmarshalling variables", err), nil
118+
}
119+
}
120+
121+
req := &dql.Request{
122+
Str: op,
123+
Variables: variablesMap,
124+
}
125+
_, err := dql.Parse(*req)
126+
if err != nil {
127+
return mcp.NewToolResultErrorFromErr("Error parsing query", err), nil
128+
}
129+
return mcp.NewToolResultText("Query is valid"), nil
130+
})
131+
75132
queryTool := mcp.NewTool("run_query",
76133
mcp.WithDescription("Run DQL Query on Dgraph"),
77134
mcp.WithString("query",
78135
mcp.Required(),
79-
mcp.Description("The query to perform"),
136+
mcp.Description("The query to run"),
80137
),
81138
mcp.WithString("variables",
82-
mcp.Required(),
83139
mcp.Description("The parameters to pass to the query in JSON format. The JSON should be a map of string keys to string, number or boolean values. Example: {\"$param1\": \"value1\", \"$param2\": 123, \"$param3\": true}"),
84140
),
85141
mcp.WithToolAnnotation(mcp.ToolAnnotation{
@@ -133,11 +189,14 @@ func NewMCPServer(connectionString string, readOnly bool) (*server.MCPServer, er
133189
return mcp.NewToolResultText("Schema updated successfully"), nil
134190
})
135191

192+
mutationArgumentDescription := `The mutation to perform in JSON format.
193+
For example: {"set": [{ "uid": "_:1", "n": "Foo", "m": 20, "p": 3.14 }]} to set a node with blank identifier _:1 with name "Foo", m=20 and p=3.14
194+
Another example: { "delete": [{ "uid": "0xfa12" }]} to delete a node with uid 0xfa12`
136195
mutationTool := mcp.NewTool("run_mutation",
137196
mcp.WithDescription("Run DQL Mutation on Dgraph"),
138197
mcp.WithString("mutation",
139198
mcp.Required(),
140-
mcp.Description("The mutation to perform in JSON format"),
199+
mcp.Description(mutationArgumentDescription),
141200
),
142201
mcp.WithToolAnnotation(mcp.ToolAnnotation{
143202
ReadOnlyHint: &False,

dgraph/cmd/mcp/mcp_server_sse_test.go

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func TestMCPSSE(t *testing.T) {
6969
}
7070

7171
if result.IsError {
72-
return "", fmt.Errorf("tool error: %v", result.Content[0])
72+
return result.Content[0].(mcp.TextContent).Text, fmt.Errorf("tool error: %v", result.Content[0])
7373
}
7474
if textContent, ok := result.Content[0].(mcp.TextContent); ok {
7575
return textContent.Text, nil
@@ -91,6 +91,7 @@ func TestMCPSSE(t *testing.T) {
9191

9292
expectedTools := []string{
9393
"get_schema",
94+
"validate_query_syntax",
9495
"run_query",
9596
"alter_schema",
9697
"run_mutation",
@@ -102,6 +103,7 @@ func TestMCPSSE(t *testing.T) {
102103
foundTools[tool.Name] = true
103104
}
104105

106+
require.Equal(t, len(expectedTools), len(foundTools), "Expected %d tools, found %d", len(expectedTools), len(foundTools))
105107
for _, expected := range expectedTools {
106108
require.True(t, foundTools[expected], "Expected tool %s to be available", expected)
107109
}
@@ -189,7 +191,7 @@ func TestMCPSSE(t *testing.T) {
189191
"mutation": `{
190192
"set": [
191193
{
192-
"uid": "_:1",
194+
"uid": "_:foo",
193195
"n": "Foo",
194196
"m": 20,
195197
"p": 3.14
@@ -218,6 +220,65 @@ func TestMCPSSE(t *testing.T) {
218220
require.Regexp(t, `^0x[0-9a-f]+$`, uidValue, "UID should be in the format 0x followed by hexadecimal digits")
219221
}
220222

223+
t.Run("ValidateQuerySyntax", func(t *testing.T) {
224+
t.Run("Valid", func(t *testing.T) {
225+
t.Run("Query", func(t *testing.T) {
226+
args := map[string]interface{}{
227+
"query": `{q(func: allofterms(name, "Foo")) { uid }}`,
228+
}
229+
resultText, err := callTool("validate_query_syntax", args)
230+
require.NoError(t, err, "ValidateQuerySyntax should not fail")
231+
require.NotEmpty(t, resultText, "Should receive validate query syntax result")
232+
require.Equal(t, "Query is valid", resultText)
233+
})
234+
t.Run("QueryWithVariables", func(t *testing.T) {
235+
args := map[string]interface{}{
236+
"query": `query me($name: string) {q(func: allofterms(name, $name)) { uid }}`,
237+
"variables": `{"$name": "Foo"}`,
238+
}
239+
resultText, err := callTool("validate_query_syntax", args)
240+
require.NoError(t, err, "ValidateQuerySyntax should not fail")
241+
require.NotEmpty(t, resultText, "Should receive validate query syntax result")
242+
require.Equal(t, "Query is valid", resultText)
243+
})
244+
})
245+
t.Run("Invalid", func(t *testing.T) {
246+
t.Run("Query", func(t *testing.T) {
247+
args := map[string]interface{}{
248+
"query": `{q(func: foo(n, "Foo")) { uid }}`,
249+
}
250+
resultText, err := callTool("validate_query_syntax", args)
251+
require.Error(t, err, "ValidateQuerySyntax should fail")
252+
require.Contains(t, resultText, "foo is not valid")
253+
})
254+
t.Run("QueryWithVariables", func(t *testing.T) {
255+
args := map[string]interface{}{
256+
"query": `query me($name: string) {q(func: allofterms(name, $name)) { uid }}`,
257+
"variables": `{"$notname": "Foo"}`,
258+
}
259+
resultText, err := callTool("validate_query_syntax", args)
260+
require.Error(t, err, "ValidateQuerySyntax should fail")
261+
require.Contains(t, resultText, "Type of variable $notname not specified")
262+
})
263+
t.Run("MisuseOfVariables", func(t *testing.T) {
264+
misuseOfVariables := `{
265+
var(func: eq(name, "Alice")) {
266+
b as uid
267+
}
268+
me(func: uid(a)) {
269+
name
270+
}
271+
}`
272+
args := map[string]interface{}{
273+
"query": misuseOfVariables,
274+
}
275+
resultText, err := callTool("validate_query_syntax", args)
276+
require.Error(t, err, "ValidateQuerySyntax should fail")
277+
require.Contains(t, resultText, "Variables are not used properly")
278+
})
279+
})
280+
})
281+
221282
t.Run("RunQuery", func(t *testing.T) {
222283
args := map[string]interface{}{
223284
"query": `{q(func: allofterms(n, "Foo")) { uid }}`,
@@ -227,16 +288,7 @@ func TestMCPSSE(t *testing.T) {
227288
require.NoError(t, err, "RunQuery should not fail")
228289
require.NotEmpty(t, resultText, "Should receive run query result")
229290

230-
var result map[string][]map[string]string
231-
err = json.Unmarshal([]byte(resultText), &result)
232-
require.NoError(t, err, "Should be able to parse JSON response")
233-
234-
require.Contains(t, result, "q", "Response should contain 'q' field")
235-
require.Len(t, result["q"], 1, "Should have exactly one result")
236-
require.Contains(t, result["q"][0], "uid", "Result should have 'uid' field")
237-
238-
uidValue := result["q"][0]["uid"]
239-
require.Regexp(t, `^0x[0-9a-f]+$`, uidValue, "UID should be in the format 0x followed by hexadecimal digits")
291+
checkUIDResult(t, resultText)
240292
})
241293

242294
t.Run("RunQueryWithEmptyArgs", func(t *testing.T) {

dgraph/cmd/mcp/prompt.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ MCP TOOLS
1414
Tools Available:
1515
- "get_schema": Gets Graph schema
1616
- "alter_schema": Alters Graph Schema.
17+
- "validate_query_syntax": Validates DQL query syntax.
1718
- "run_query": Executes DQL statements queries on the provided Dgraph connection and returns results. Variables are optional.
1819
- "run_mutation": Executes DQL statements mutations on the provided Dgraph connection and returns results.
1920
- "get_common_queries": Some common queries that can be done on the Graph
@@ -37,6 +38,7 @@ WORKFLOW
3738
3. Query Execution:
3839
- Interpret user queries about data retrieval or analysis.
3940
- Match user intent to schema structure and generate DQL.
41+
- Use the `validate_query_syntax` tool to validate the DQL query syntax.
4042
- Use the `run_query` tool to run the DQL and return results.
4143
- Provide explanations for the query structure and results, especially for less technical users.
4244

@@ -96,6 +98,7 @@ CONVERSATION FLOW
9698
- Confirm which predicates or types are relevant.
9799
- Fetch schema if necessary.
98100
- Generate the corresponding DQL queries.
101+
- Use the `validate_query_syntax` tool to validate the DQL query syntax.
99102
- Execute and present results.
100103
- Visualize where helpful.
101104

0 commit comments

Comments
 (0)