Skip to content

Commit 72146a4

Browse files
fix(oracle): enable DML operations and resolve incorrect array type error (googleapis#2323)
This pull request resolves an issue where executing DML statements (UPDATE, INSERT, DELETE) caused "incorrect array type" errors. Previously, the Oracle source forced all operations to use QueryContext, expecting rows to be returned. This caused failures when running write operations that do not return rows. **Changes Implemented:** The modification updates the Oracle source and tool definitions to distinguish between Read-Only (SELECT) and Action (DML) operations. - **internal/sources/oracle/oracle.go:** 1. Updated RunSQL signature to accept a readOnly boolean. 2. Added conditional logic: If readOnly is false, it now uses ExecContext and returns rows_affected instead of attempting to scan non-existent rows. - **internal/tools/oracle/oraclesql/oraclesql.go:** 1. Added a readonly field to the tool configuration (YAML). 2. Updated the Invoke method to pass this flag to the source. Defaults to true (Read-Only) for backward compatibility. 🛠️ Fixes googleapis#2026 --------- Co-authored-by: Wenxin Du <117315983+duwenxin99@users.noreply.github.com>
1 parent 00c6b2c commit 72146a4

File tree

6 files changed

+241
-30
lines changed

6 files changed

+241
-30
lines changed

docs/en/resources/tools/oracle/oracle-sql.md

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ The specified SQL statement is executed using [prepared statements][oracle-stmt]
1919
for security and performance. It expects parameter placeholders in the SQL query
2020
to be in the native Oracle format (e.g., `:1`, `:2`).
2121

22+
By default, tools are configured as **read-only** (SAFE mode). To execute data modification
23+
statements (INSERT, UPDATE, DELETE), you must explicitly set the `readOnly`
24+
field to `false`.
25+
2226
[oracle-stmt]: https://docs.oracle.com/javase/tutorial/jdbc/basics/prepared.html
2327

2428
## Example
@@ -29,29 +33,46 @@ to be in the native Oracle format (e.g., `:1`, `:2`).
2933
> names, or other parts of the query.
3034
3135
```yaml
32-
kind: tools
33-
name: search_flights_by_number
34-
type: oracle-sql
35-
source: my-oracle-instance
36-
statement: |
37-
SELECT * FROM flights
38-
WHERE airline = :1
39-
AND flight_number = :2
40-
FETCH FIRST 10 ROWS ONLY
41-
description: |
42-
Use this tool to get information for a specific flight.
43-
Takes an airline code and flight number and returns info on the flight.
44-
Do NOT use this tool with a flight id. Do NOT guess an airline code or flight number.
45-
Example:
46-
{{
47-
"airline": "CY",
48-
"flight_number": "888",
49-
}}
50-
parameters:
51-
- name: airline
52-
type: string
53-
description: Airline unique 2 letter identifier
54-
- name: flight_number
55-
type: string
56-
description: 1 to 4 digit number
57-
```
36+
tools:
37+
search_flights_by_number:
38+
kind: oracle-sql
39+
source: my-oracle-instance
40+
statement: |
41+
SELECT * FROM flights
42+
WHERE airline = :1
43+
AND flight_number = :2
44+
FETCH FIRST 10 ROWS ONLY
45+
description: |
46+
Use this tool to get information for a specific flight.
47+
Takes an airline code and flight number and returns info on the flight.
48+
Do NOT use this tool with a flight id. Do NOT guess an airline code or flight number.
49+
Example:
50+
{{
51+
"airline": "CY",
52+
"flight_number": "888",
53+
}}
54+
parameters:
55+
- name: airline
56+
type: string
57+
description: Airline unique 2 letter identifier
58+
- name: flight_number
59+
type: string
60+
description: 1 to 4 digit number
61+
62+
update_flight_status:
63+
kind: oracle-sql
64+
source: my-oracle-instance
65+
readOnly: false # Required for INSERT/UPDATE/DELETE
66+
statement: |
67+
UPDATE flights
68+
SET status = :1
69+
WHERE airline = :2 AND flight_number = :3
70+
description: Updates the status of a specific flight.
71+
parameters:
72+
- name: status
73+
type: string
74+
- name: airline
75+
type: string
76+
- name: flight_number
77+
type: string
78+

internal/sources/oracle/oracle.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,23 @@ func (s *Source) OracleDB() *sql.DB {
136136
return s.DB
137137
}
138138

139-
func (s *Source) RunSQL(ctx context.Context, statement string, params []any) (any, error) {
139+
func (s *Source) RunSQL(ctx context.Context, statement string, params []any, readOnly bool) (any, error) {
140+
if !readOnly {
141+
result, err := s.OracleDB().ExecContext(ctx, statement, params...)
142+
if err != nil {
143+
return nil, fmt.Errorf("unable to execute DML statement: %w", err)
144+
}
145+
146+
rowsAffected, err := result.RowsAffected()
147+
if err != nil {
148+
return nil, fmt.Errorf("unable to get rows affected: %w", err)
149+
}
150+
151+
return map[string]any{
152+
"status": "success",
153+
"rows_affected": rowsAffected,
154+
}, nil
155+
}
140156
rows, err := s.OracleDB().QueryContext(ctx, statement, params...)
141157
if err != nil {
142158
return nil, fmt.Errorf("unable to execute query: %w", err)

internal/sources/oracle/oracle_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package oracle_test
44

55
import (
66
"context"
7+
"database/sql"
78
"strings"
89
"testing"
910

@@ -191,3 +192,36 @@ func TestFailParseFromYaml(t *testing.T) {
191192
})
192193
}
193194
}
195+
196+
// TestRunSQLExecutesDML verifies that RunSQL correctly routes operations to
197+
// ExecContext instead of QueryContext when the readOnly flag is set to false.
198+
func TestRunSQLExecutesDML(t *testing.T) {
199+
// Initialize a mock database connection.
200+
// This connection is not established with a real backend but
201+
// satisfies the interface requirements for the test.
202+
db, err := sql.Open("oracle", "oracle://user:pass@localhost:1521/service")
203+
if err != nil {
204+
t.Fatalf("failed to open mock db: %v", err)
205+
}
206+
defer db.Close()
207+
208+
src := &oracle.Source{
209+
Config: oracle.Config{
210+
Name: "test-dml-source",
211+
Type: oracle.SourceType,
212+
User: "test-user",
213+
},
214+
DB: db,
215+
}
216+
217+
// Invoke RunSQL with readOnly=false to force the DML execution path.
218+
_, err = src.RunSQL(context.Background(),
219+
"UPDATE users SET email='x' WHERE id=1", nil, false)
220+
221+
// We expect an error because the mock database cannot execute the query.
222+
// If err is nil, it implies the logic skipped the execution block.
223+
if err == nil {
224+
t.Fatal("expected error from fake DB execution, but got nil; " +
225+
"DML path may not have been executed")
226+
}
227+
}

internal/tools/oracle/oraclesql/oraclesql.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.T
3434

3535
type compatibleSource interface {
3636
OracleDB() *sql.DB
37-
RunSQL(context.Context, string, []any) (any, error)
37+
RunSQL(context.Context, string, []any, bool) (any, error)
3838
}
3939

4040
type Config struct {
@@ -43,6 +43,7 @@ type Config struct {
4343
Source string `yaml:"source" validate:"required"`
4444
Description string `yaml:"description" validate:"required"`
4545
Statement string `yaml:"statement" validate:"required"`
46+
ReadOnly *bool `yaml:"readOnly"`
4647
AuthRequired []string `yaml:"authRequired"`
4748
Parameters parameters.Parameters `yaml:"parameters"`
4849
TemplateParameters parameters.Parameters `yaml:"templateParameters"`
@@ -105,7 +106,14 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para
105106
fmt.Printf("[%d]=%T ", i, p)
106107
}
107108
fmt.Printf("\n")
108-
resp, err := source.RunSQL(ctx, newStatement, sliceParams)
109+
110+
isReadOnly := true
111+
if t.ReadOnly != nil {
112+
isReadOnly = *t.ReadOnly
113+
}
114+
115+
resp, err := source.RunSQL(ctx, newStatement, sliceParams, isReadOnly)
116+
109117
if err != nil {
110118
return nil, util.ProcessGeneralError(err)
111119
}

internal/tools/oracle/oraclesql/oraclesql_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ func TestParseFromYamlOracleSql(t *testing.T) {
1515
if err != nil {
1616
t.Fatalf("unexpected error: %s", err)
1717
}
18+
19+
valTrue := true
20+
valFalse := false
21+
1822
tcs := []struct {
1923
desc string
2024
in string
@@ -39,6 +43,7 @@ func TestParseFromYamlOracleSql(t *testing.T) {
3943
Source: "my-oracle-instance",
4044
Description: "Retrieves user details by ID.",
4145
Statement: "SELECT id, name, email FROM users WHERE id = :1",
46+
ReadOnly: nil,
4247
AuthRequired: []string{"my-google-auth-service"},
4348
},
4449
},
@@ -60,6 +65,53 @@ func TestParseFromYamlOracleSql(t *testing.T) {
6065
Source: "db-prod",
6166
Description: "Gets orders for a customer with optional filtering.",
6267
Statement: "SELECT * FROM ${SCHEMA}.ORDERS WHERE customer_id = :customer_id AND status = :status",
68+
ReadOnly: nil,
69+
AuthRequired: []string{},
70+
},
71+
},
72+
},
73+
{
74+
desc: "explicit: readOnly set to true",
75+
in: `
76+
kind: tools
77+
name: safe_query
78+
type: oracle-sql
79+
source: db-prod
80+
description: Safe read operation.
81+
readOnly: true
82+
statement: "SELECT * FROM orders"
83+
`,
84+
want: server.ToolConfigs{
85+
"safe_query": oraclesql.Config{
86+
Name: "safe_query",
87+
Type: "oracle-sql",
88+
Source: "db-prod",
89+
Description: "Safe read operation.",
90+
Statement: "SELECT * FROM orders",
91+
ReadOnly: &valTrue,
92+
AuthRequired: []string{},
93+
},
94+
},
95+
},
96+
{
97+
desc: "example with readonly flag set to false (DML)",
98+
in: `
99+
kind: tools
100+
name: update_user
101+
type: oracle-sql
102+
source: db-prod
103+
description: Updates user email.
104+
readOnly: false
105+
statement: "UPDATE users SET email = :1 WHERE id = :2"
106+
`,
107+
want: server.ToolConfigs{
108+
"update_user": oraclesql.Config{
109+
Name: "update_user",
110+
Type: "oracle-sql",
111+
Source: "db-prod",
112+
Description: "Updates user email.",
113+
Statement: "UPDATE users SET email = :1 WHERE id = :2",
114+
ReadOnly: &valFalse,
63115
AuthRequired: []string{},
64116
},
65117
},

tests/oracle/oracle_integration_test.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
package oracle
44

55
import (
6+
"bytes"
67
"context"
78
"database/sql"
89
"fmt"
10+
"io"
11+
"net/http"
912
"os"
1013
"regexp"
1114
"strings"
@@ -103,6 +106,34 @@ func TestOracleSimpleToolEndpoints(t *testing.T) {
103106
tmplSelectCombined, tmplSelectFilterCombined := tests.GetMySQLTmplToolStatement()
104107
toolsFile = tests.AddTemplateParamConfig(t, toolsFile, OracleToolType, tmplSelectCombined, tmplSelectFilterCombined, "")
105108

109+
// Configure a DML tool to verify the 'readonly: false' logic.
110+
updateStmt := fmt.Sprintf(`UPDATE %s SET "name" = :1 WHERE "id" = :2`, tableNameParam)
111+
112+
toolsMap, ok := toolsFile["tools"].(map[string]any)
113+
if !ok {
114+
t.Fatal("Configuration error: 'tools' key is missing or is not a map")
115+
}
116+
117+
toolsMap["my-update-tool"] = map[string]any{
118+
"type": "oracle-sql",
119+
"source": "my-instance",
120+
"statement": updateStmt,
121+
"readOnly": false,
122+
"description": "Update user name by ID.",
123+
"parameters": []map[string]any{
124+
{
125+
"name": "name",
126+
"type": "string",
127+
"description": "The new name for the user.",
128+
},
129+
{
130+
"name": "id",
131+
"type": "integer",
132+
"description": "The user ID to update.",
133+
},
134+
},
135+
}
136+
106137
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...)
107138
if err != nil {
108139
t.Fatalf("command initialization returned an error: %s", err)
@@ -119,7 +150,7 @@ func TestOracleSimpleToolEndpoints(t *testing.T) {
119150

120151
// Get configs for tests
121152
select1Want := "[{\"1\":1}]"
122-
mcpMyFailToolWant := `{"jsonrpc":"2.0","id":"invoke-fail-tool","result":{"content":[{"type":"text","text":"error processing request: unable to execute query: dpiStmt_execute: ORA-00900: invalid SQL statement"}],"isError":true}}`
153+
mcpMyFailToolWant := `{"jsonrpc":"2.0","id":"invoke-fail-tool","result":{"content":[{"type":"text","text":"unable to execute query: dpiStmt_execute: ORA-00900: invalid SQL statement\nHelp: https://docs.oracle.com/error-help/db/ora-00900/"}],"isError":true}}`
123154
createTableStatement := `"CREATE TABLE t (id NUMBER GENERATED AS IDENTITY PRIMARY KEY, name VARCHAR2(255))"`
124155
mcpSelect1Want := `{"jsonrpc":"2.0","id":"invoke my-auth-required-tool","result":{"content":[{"type":"text","text":"{\"1\":1}"}]}}`
125156

@@ -133,6 +164,11 @@ func TestOracleSimpleToolEndpoints(t *testing.T) {
133164
tests.RunMCPToolCallMethod(t, mcpMyFailToolWant, mcpSelect1Want)
134165
tests.RunExecuteSqlToolInvokeTest(t, createTableStatement, select1Want)
135166
tests.RunToolInvokeWithTemplateParameters(t, tableNameTemplateParam)
167+
168+
// Invoke the 'my-update-tool' and verify the result.
169+
testDmlQueries(t, "my-update-tool",
170+
`{"name": "UpdatedAlice", "id": 1}`,
171+
`\"rows_affected\":1`)
136172
}
137173

138174
func setupOracleTable(t *testing.T, ctx context.Context, pool *sql.DB, createStatement, insertStatement, tableName string, params []any) func(*testing.T) {
@@ -242,3 +278,47 @@ func dropAllUserTables(t *testing.T, ctx context.Context, db *sql.DB) {
242278
}
243279
}
244280
}
281+
282+
// testDmlQueries sends a JSON-RPC request to the running test server
283+
// and asserts that the response body contains the expected substring.
284+
func testDmlQueries(t *testing.T, toolName, paramsJSON, wantResponseSubStr string) {
285+
t.Helper()
286+
287+
// The test server typically runs on port 8080
288+
url := "http://localhost:5000/mcp"
289+
290+
// Construct the JSON-RPC request body
291+
reqBody := fmt.Sprintf(`{
292+
"jsonrpc": "2.0",
293+
"method": "tools/call",
294+
"params": {
295+
"name": "%s",
296+
"arguments": %s
297+
},
298+
"id": 1
299+
}`, toolName, paramsJSON)
300+
301+
req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(reqBody)))
302+
if err != nil {
303+
t.Fatalf("Failed to create HTTP request: %v", err)
304+
}
305+
req.Header.Set("Content-Type", "application/json")
306+
307+
client := &http.Client{Timeout: 5 * time.Second}
308+
resp, err := client.Do(req)
309+
if err != nil {
310+
t.Fatalf("Failed to send request to %s: %v", url, err)
311+
}
312+
defer resp.Body.Close()
313+
314+
body, err := io.ReadAll(resp.Body)
315+
if err != nil {
316+
t.Fatalf("Failed to read response body: %v", err)
317+
}
318+
319+
// Verify that the response contains the expected success message (e.g., "rows_affected")
320+
if !strings.Contains(string(body), wantResponseSubStr) {
321+
t.Errorf("Tool invocation '%s' failed.\nGot response: %s\nExpected substring: %s",
322+
toolName, string(body), wantResponseSubStr)
323+
}
324+
}

0 commit comments

Comments
 (0)