Skip to content

Add MCP tool for querying a service#42

Merged
nathanjcochran merged 15 commits intomainfrom
nathan/mcp-query-tool
Oct 6, 2025
Merged

Add MCP tool for querying a service#42
nathanjcochran merged 15 commits intomainfrom
nathan/mcp-query-tool

Conversation

@nathanjcochran
Copy link
Member

@nathanjcochran nathanjcochran commented Oct 4, 2025

Adds a new db_execute_query tool (I omitted the tiger_ prefix, per the changes in #30) that is capable of executing a SQL query and returning the results.

I made a handful of small changes compared to the original spec, such as:

  • Making the service ID parameter required, instead of optional. None of the other MCP tools currently utilize the default service, and given that it could be risky to run a SQL query against the wrong database, I figured it would be better to make it explicit. The LLM is able to list services and get service IDs, so it shouldn't really cause much friction.
  • Changing timeout to timeout_seconds for clarity (similar changes were made in feat: comprehensive MCP service tool improvements #30).
  • Adding role and pooled parameters for parity with the tiger db connect flags.
  • Returning column types as well as names in the response.
  • Returning "rows affected" instead of "row count". Rows affected essentially is the row count for row-returning queries (e.g. SELECT), but also shows the number of rows modified for INSERT/UPDATE/DELETE queries, making it useful in those scenarios as well.
  • Returning the execution time as a string in Go's standard time.Duration output format, rather than in milliseconds. For long-running queries, this will hopefully make the duration easier for both humans and LLMs to parse and understand.

I moved the logic for building connection strings from the internal/tiger/cmd package into internal/tiger/password so that it could be shared with the MCP tools. That seemed like a fairly reasonable place to put it, even if not entirely ideal. The code depends on the internal/tiger/api package, so it can't go in util, and I didn't want to make another package just for the connection string logic (I think having a bunch of very small packages is kind of an anti-pattern in Go). I think we may need to rethink our package organization at some point, but this seemed good enough for now 🤷‍♂️.

Note that there was talk about also adding an MCP tool for executing a query from a file (see here). I did not do that in this PR, but it should be easy to do in a follow-up if we want it.

Closes AGE-159

@nathanjcochran nathanjcochran self-assigned this Oct 4, 2025
@linear
Copy link

linear bot commented Oct 5, 2025

AGE-159 Tiger CLI: Database tool calls

I did not implement the Database Operations tool calls from the v0 Tool Priority section of the MCP spec in AGE-121. We should implement them.

tiger_db_execute_query is highest priority (ideally by launch)

Comment on lines +208 to +214
// Build connection string for testing with password (if available)
connectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{
Pooled: dbTestConnectionPooled,
Role: dbTestConnectionRole,
PasswordMode: password.PasswordOptional,
WarnWriter: cmd.ErrOrStderr(),
})
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the only place we use PasswordOptional. That causes BuildConnectionString to attempt to include the password if possible, but to return the connection string without the password if the password can't be found.

The previous code would build the connection string without the password, but then testDatabaseConnection would call buildConnectionConfig, which parsed the connection string into a config struct and attempted to add the password to the config, if possible. That seemed like a pretty roundabout way of doing it (building a connection string just to parse it back into a config), so I got rid of that logic and added the PasswordOptional option when building the connection string.

That being said, I'm not 100% convinced it's worth it to return the connection string without a password here. The old code in buildConnectionConfig had this comment:

// Note: If keyring password retrieval fails, we let pgx try without it
// This allows fallback to other authentication methods

But I'm not sure what "other authentication methods" it's talking about here, or if it's really worth worrying about it. If we don't need to support this, we could get rid of PasswordOptional and replace PasswordMode with a PasswordRequired boolean, which would probably make the code a little more straightforward/clear.

Copy link
Contributor

Choose a reason for hiding this comment

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

The other method I originally had in mind was PGPASSWORD env variable being set in the shell or a .pgpassword file (even if not set by the cli). I still think its worth allowing this kind of fallback.

@nathanjcochran nathanjcochran marked this pull request as ready for review October 5, 2025 20:00
@nathanjcochran nathanjcochran requested a review from cevian October 5, 2025 20:00
Comment on lines +208 to +214
// Build connection string for testing with password (if available)
connectionString, err := password.BuildConnectionString(service, password.ConnectionStringOptions{
Pooled: dbTestConnectionPooled,
Role: dbTestConnectionRole,
PasswordMode: password.PasswordOptional,
WarnWriter: cmd.ErrOrStderr(),
})
Copy link
Contributor

Choose a reason for hiding this comment

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

The other method I originally had in mind was PGPASSWORD env variable being set in the shell or a .pgpassword file (even if not set by the cli). I still think its worth allowing this kind of fallback.

// registerDatabaseTools registers database operation tools with comprehensive schemas and descriptions
func (s *Server) registerDatabaseTools() {
mcp.AddTool(s.mcpServer, &mcp.Tool{
Name: "db_execute_query",
Copy link
Contributor

Choose a reason for hiding this comment

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

as a followup/discussion point: should we have a separate tool for "read-only" that make sure you are using a read-only role or connection? Alternatively, we can have another input parameter "enforce read-only"

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I think a separate input parameter is probably the way to go. I do think we should do that in a follow-up though.

// DBExecuteQueryInput represents input for tiger_db_execute_query
type DBExecuteQueryInput struct {
ServiceID string `json:"service_id"`
Query string `json:"query"`
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should also take in query parameter array for $1, $2 substitution to help llm avoid sql injection

Copy link
Member Author

@nathanjcochran nathanjcochran Oct 6, 2025

Choose a reason for hiding this comment

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

I considered this too, but on further reflection, I'm not really sure what it would be guarding against. If the entire SQL query is crafted by the LLM anyways, what's the value of having it pass parameters separately? It isn't really like a normal backend, where you have predefined "safe" queries that you want to parameterize with "unsafe" user-provided input. In this case, both the queries and the parameters are coming from the same LLM.

That being said, I'm not opposed to supporting parameters, as a way of giving the LLM more flexibility. For example, if a user provided a query with placeholders, it would allow the LLM to just execute it directly with specific parameters without having to modify the query. So yeah, I can implement this (I just think it's worth pointing out that I don't really think it affects security in any meaningful way).

Copy link
Member Author

Choose a reason for hiding this comment

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

Support for parameterized queries added here: 178a33d

schema.Properties["role"].Examples = []any{"tsdbadmin", "readonly", "postgres"}

schema.Properties["pooled"].Description = "Use connection pooling (if available for the service)"
schema.Properties["pooled"].Default = util.Must(json.Marshal(false))
Copy link
Contributor

Choose a reason for hiding this comment

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

I think default should be true?

Copy link
Member Author

Choose a reason for hiding this comment

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

It defaults to false for tiger db connect. What's your rationale for the difference?

Copy link
Contributor

Choose a reason for hiding this comment

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

@nathanjcochran nathanjcochran requested a review from cevian October 6, 2025 16:15
schema.Properties["role"].Examples = []any{"tsdbadmin", "readonly", "postgres"}

schema.Properties["pooled"].Description = "Use connection pooling (if available for the service)"
schema.Properties["pooled"].Default = util.Must(json.Marshal(false))
Copy link
Contributor

Choose a reason for hiding this comment

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

@nathanjcochran nathanjcochran merged commit daa909a into main Oct 6, 2025
1 check passed
@nathanjcochran nathanjcochran deleted the nathan/mcp-query-tool branch October 6, 2025 18:13
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