Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,24 @@ While this provides a good starting point for LLMs ensuring it has full context

To disable this requirement, set the `--skip-docs-proof` CLI bool flag (or the `FIREBOLT_MCP_SKIP_DOCS_PROOF` environment variable) to `false`.

#### Read-only mode

Set the `--read-only` CLI bool flag (or the `FIREBOLT_MCP_READ_ONLY` environment variable) to restrict `firebolt_query`
to a single `SELECT` or `WITH` statement and declare it read-only in its MCP tool annotations
(`readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false`).
Clients that cache results of read-only tools can then replay them instead of re-running the query.

The statement check skips comments, string literals (including dollar-quoted `$$...$$` and `$tag$...$tag$`) and quoted identifiers. It rejects a second statement after `;`,
any statement that does not start with `SELECT` or `WITH`, and any bare write or DDL keyword (`INSERT`, `UPDATE`, `DELETE`, `MERGE`,
`TRUNCATE`, `COPY`, `CREATE`, `ALTER`, `DROP`, `UNDROP`, `VACUUM`, `GRANT`, `REVOKE`, `CALL`).
It is conservative: `SHOW`, `EXPLAIN` and pipe syntax starting with `FROM` are rejected, and so is an unquoted identifier that matches one of these keywords.

The check is defence in depth and is not a security boundary. Enable read-only mode only when the service account's
Firebolt role has no write or DDL privileges. Clients trust the annotation and may cache and replay results.

The other tools (`firebolt_connect`, `firebolt_connect_core`, `firebolt_docs_overview`, `firebolt_docs_llm`, `firebolt_docs_search`)
never write, so they always declare `readOnlyHint: true`.

## Architecture

Firebolt MCP Server implements the [Model Context Protocol](https://modelcontextprotocol.io/introduction), providing:
Expand Down
9 changes: 9 additions & 0 deletions cmd/firebolt-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ func main() {
"will not be forced to gather more starting context and become smarter, but this means more tokens consumed and slower responses.",
Sources: cli.EnvVars("FIREBOLT_MCP_SKIP_DOCS_PROOF"),
},
&cli.BoolFlag{
Name: "read-only",
Category: "MCP Tools Configuration",
Value: false,
Usage: "Accept only a single SELECT or WITH statement in firebolt_query and annotate it as read-only, " +
"so MCP clients may cache its results. Use only with a Firebolt user whose role cannot write.",
Sources: cli.EnvVars("FIREBOLT_MCP_READ_ONLY"),
},
},
Action: run,
}
Expand All @@ -129,6 +137,7 @@ func run(ctx context.Context, cmd *cli.Command) error {
Environment: cmd.String("environment"),
CoreURL: cmd.String("core-url"),
SkipDocsProof: cmd.Bool("skip-docs-proof"),
ReadOnly: cmd.Bool("read-only"),
}

if err := cfg.Validate(); err != nil {
Expand Down
9 changes: 7 additions & 2 deletions cmd/firebolt-mcp-server/prepare_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ func prepareToolsAndResourceTemplates(ctx context.Context, logger *slog.Logger,
docsProof = &docsProofToken
}

var queryOpts []tool_query.Option
if cfg.ReadOnly {
queryOpts = append(queryOpts, tool_query.WithReadOnly())
}

// resources shared between Firebolt and Firebolt Core
resourceDocs := resources.NewDocs(fireboltdocs.FS, docsProofToken)

Expand All @@ -42,7 +47,7 @@ func prepareToolsAndResourceTemplates(ctx context.Context, logger *slog.Logger,
tool_connect_core.NewConnectCore(resourceCoreAccounts, resourceDatabases, resourceEngines, docsProof, cfg.DisableResources),
tool_docs.NewDocs(resourceDocs, cfg.DisableResources, true),
tool_docs_llm.NewDocsLLM(resourceLLMDocs, cfg.DisableResources),
tool_query.NewQuery(dbPool),
tool_query.NewQuery(dbPool, queryOpts...),
}
resourceTemplates := []server.ResourceTemplate{
resourceDocs,
Expand Down Expand Up @@ -86,7 +91,7 @@ func prepareToolsAndResourceTemplates(ctx context.Context, logger *slog.Logger,
tools := []server.Tool{
tool_connect.NewConnect(resourceAccounts, resourceDatabases, resourceEngines, docsProof, cfg.DisableResources),
tool_docs.NewDocs(resourceDocs, cfg.DisableResources, false),
tool_query.NewQuery(dbPool),
tool_query.NewQuery(dbPool, queryOpts...),
docsSearchTool,
}

Expand Down
1 change: 1 addition & 0 deletions pkg/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Config struct {
CoreURL string

SkipDocsProof bool
ReadOnly bool
}

func (c Config) Validate() error {
Expand Down
22 changes: 22 additions & 0 deletions pkg/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/firebolt-db/mcp-server/pkg/clients/database/databasemock"
"github.com/firebolt-db/mcp-server/pkg/prompts"
"github.com/firebolt-db/mcp-server/pkg/server"
"github.com/firebolt-db/mcp-server/pkg/tools/tool_query"
)

// TestNewServer checks that NewServer registers tools, prompts, and resource templates correctly.
Expand Down Expand Up @@ -439,6 +441,26 @@ func (f *funcTool) Register(s *mcp.Server) {
})
}

// TestServeStreamableHTTP_ToolAnnotations checks that tool annotations survive
// registration and serialization, so clients see them in tools/list.
func TestServeStreamableHTTP_ToolAnnotations(t *testing.T) {
addr := freeAddr(t)
tools := []server.Tool{tool_query.NewQuery(databasemock.NewPoolMock(), tool_query.WithReadOnly())}
srv := server.NewServer(slogt.New(t), "0.0.0", "streamable-http", addr, tools, nil, nil)
cancel, serveErr := serve(t, srv)

session := connect(t, addr, false)
res, err := session.ListTools(t.Context(), nil)
require.NoError(t, err)
require.Len(t, res.Tools, 1)
require.NotNil(t, res.Tools[0].Annotations)
assert.True(t, res.Tools[0].Annotations.ReadOnlyHint)
require.NoError(t, session.Close())

cancel()
require.NoError(t, <-serveErr, "Serve should shut down cleanly")
}

// mockTool is a mock implementation of the Tool interface for testing.
type mockTool struct {
mock.Mock
Expand Down
1 change: 1 addition & 0 deletions pkg/tools/tool_connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func (t *Connect) Tool() *mcp.Tool {
Title: "Firebolt Connect",
Description: "Returns a list of Firebolt accounts, databases, and engines you have access to. " +
"This information is required before using the `firebolt_query` tool.",
Annotations: tools.ReadOnlyAnnotations(),
}
}

Expand Down
1 change: 1 addition & 0 deletions pkg/tools/tool_connect_core/connect_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func (t *ConnectCore) Tool() *mcp.Tool {
Title: "Firebolt Core Connect",
Description: "Returns a list of databases you have access to in Firebolt Core. " +
"This information is required before using the `firebolt_query` tool.",
Annotations: tools.ReadOnlyAnnotations(),
}
}

Expand Down
1 change: 1 addition & 0 deletions pkg/tools/tool_docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (t *Docs) Tool() *mcp.Tool {
"Use this tool when need to get general information about Firebolt or need to connect to and use Firebolt. " +
"Firebolt differs significantly from other databases, so it's important to gather some initial information before providing accurate answers. " +
"To search for detailed information about using Firebolt, query syntax, object types etc use `firebolt_docs_search` tool",
Annotations: tools.ReadOnlyAnnotations(),
}
}

Expand Down
1 change: 1 addition & 0 deletions pkg/tools/tool_docs_llm/docs_llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func (t *DocsLLM) Tool() *mcp.Tool {
Description: "Returns Firebolt documentation articles prepared to be used by LLMs. " +
"Use this tool without parameters to list index of all Firebolt documentation articles. " +
"Use this tool with an article URL (provide a full url as stated in the index article) to retrieve a specific documentation article.",
Annotations: tools.ReadOnlyAnnotations(),
}
}

Expand Down
40 changes: 36 additions & 4 deletions pkg/tools/tool_query/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/firebolt-db/mcp-server/pkg/clients/database"
"github.com/firebolt-db/mcp-server/pkg/tools"
)

type Input struct {
Expand All @@ -25,28 +26,54 @@ type Output struct {
// It handles connecting to the appropriate account, database, and engine before
// executing the query and returning results.
type Query struct {
dbPool database.Pool
dbPool database.Pool
readOnly bool
}

// Option configures the Query tool.
type Option func(*Query)

// WithReadOnly restricts the tool to a single SELECT or WITH statement and
// declares it read-only in its MCP annotations, so clients may cache its results.
func WithReadOnly() Option {
return func(q *Query) {
q.readOnly = true
}
}

// NewQuery creates a new instance of the Query tool with the provided database connection pool.
// The connection pool is responsible for managing connections to different Firebolt
// account/database/engine combinations.
func NewQuery(dbPool database.Pool) *Query {
return &Query{
func NewQuery(dbPool database.Pool, opts ...Option) *Query {
q := &Query{
dbPool: dbPool,
}
for _, opt := range opts {
opt(q)
}
return q
}

// Tool returns the mcp.Tool definition for the Query tool.
// This defines how the tool is represented in the MCP system, including:
// - The tool's name and description
// - Required and optional parameters with their descriptions
func (t *Query) Tool() *mcp.Tool {
return &mcp.Tool{
tool := &mcp.Tool{
Name: "firebolt_query",
Title: "Firebolt Query",
Description: "Execute an SQL query against Firebolt.",
}
if t.readOnly {
tool.Description += " Read-only: only a single SELECT or WITH statement is accepted."
tool.Annotations = &mcp.ToolAnnotations{
ReadOnlyHint: true,
DestructiveHint: tools.Ptr(false),
IdempotentHint: true,
OpenWorldHint: tools.Ptr(false),
}
}
return tool
}

// Register adds the Query tool to the provided MCP server instance.
Expand All @@ -68,6 +95,11 @@ func (t *Query) Handler() mcp.ToolHandlerFor[Input, *Output] {
if input.Account == "" {
return nil, nil, fmt.Errorf("bad request: account parameter is required")
}
if t.readOnly {
if err := validateReadOnly(input.Query); err != nil {
return nil, nil, err
}
}

var (
dbName *string
Expand Down
158 changes: 158 additions & 0 deletions pkg/tools/tool_query/readonly.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package tool_query

import (
"errors"
"fmt"
"strings"
"unicode"
)

// writeKeywords are rejected anywhere in a read-only query, outside of literals and comments.
// This catches data-modifying CTEs such as `WITH x AS (...) INSERT INTO ...`.
var writeKeywords = map[string]struct{}{
"INSERT": {}, "UPDATE": {}, "DELETE": {}, "MERGE": {}, "TRUNCATE": {}, "COPY": {},
"CREATE": {}, "ALTER": {}, "DROP": {}, "UNDROP": {}, "VACUUM": {},
"GRANT": {}, "REVOKE": {}, "CALL": {},
}

var errMultipleStatements = errors.New("bad request: read-only mode: only a single statement is allowed")

// validateReadOnly accepts a single SELECT or WITH statement and rejects anything else.
// It is defence in depth on top of the Firebolt role's grants, not a substitute for them:
// a keyword scan can be bypassed, the role cannot.
func validateReadOnly(sql string) error {
words, err := scanWords(sql)
if err != nil {
return err
}
if len(words) == 0 {
return errors.New("bad request: read-only mode: query contains no statement")
}

if first := words[0]; first != "SELECT" && first != "WITH" {
return fmt.Errorf("bad request: read-only mode: only SELECT or WITH statements are allowed, got %s", first)
}
for _, w := range words {
if _, ok := writeKeywords[w]; ok {
return fmt.Errorf("bad request: read-only mode: %s is not allowed", w)
}
}

return nil
}

// scanWords returns the upper-cased bare words of sql, skipping string literals, quoted
// identifiers and comments. It fails if anything but comments follows a `;`.
func scanWords(sql string) ([]string, error) {
var (
words []string
ended bool
r = []rune(sql)
n = len(r)
)

for i := 0; i < n; {
c := r[i]
switch {
case unicode.IsSpace(c):
i++
case c == '-' && i+1 < n && r[i+1] == '-':
for i < n && r[i] != '\n' {
i++
}
case c == '/' && i+1 < n && r[i+1] == '*':
j := i + 2
for j+1 < n && (r[j] != '*' || r[j+1] != '/') {
j++
}
if j+1 >= n {
return nil, errors.New("bad request: read-only mode: unterminated comment")
}
i = j + 2
case ended:
return nil, errMultipleStatements
case c == ';':
ended = true
i++
case c == '$' && (i == 0 || !isIdentPart(r[i-1])):
j, err := skipDollarQuoted(r, i)
if err != nil {
return nil, err
}
i = j
Comment thread
cursor[bot] marked this conversation as resolved.
case c == '\'' || c == '"':
backslashEscapes := c == '\'' && isEscapeStringPrefix(r, i)
j, err := skipQuoted(r, i, backslashEscapes)
if err != nil {
return nil, err
}
i = j
case isIdentStart(c):
j := i
for j < n && isIdentPart(r[j]) {
j++
}
words = append(words, strings.ToUpper(string(r[i:j])))
i = j
default:
i++
}
}

return words, nil
}
Comment thread
cursor[bot] marked this conversation as resolved.

// skipQuoted returns the index just past the quoted run starting at r[start].
// A doubled quote is an escaped quote; in E'...' strings a backslash also escapes.
func skipQuoted(r []rune, start int, backslashEscapes bool) (int, error) {
quote := r[start]
for i := start + 1; i < len(r); i++ {
switch {
case backslashEscapes && r[i] == '\\':
i++
case r[i] != quote:
case i+1 < len(r) && r[i+1] == quote:
i++
default:
return i + 1, nil
}
}
return 0, errors.New("bad request: read-only mode: unterminated quoted string")
}

// skipDollarQuoted returns the index just past a $$...$$ or $tag$...$tag$ literal starting
// at r[start], or start+1 when the `$` does not open one (for example a $1 parameter).
func skipDollarQuoted(r []rune, start int) (int, error) {
j := start + 1
if j < len(r) && isIdentStart(r[j]) {
for j < len(r) && (isIdentStart(r[j]) || unicode.IsDigit(r[j])) {
j++
}
}
if j >= len(r) || r[j] != '$' {
return start + 1, nil
}
tag := r[start : j+1]
for k := j + 1; k+len(tag) <= len(r); k++ {
if string(r[k:k+len(tag)]) == string(tag) {
return k + len(tag), nil
}
}
return 0, errors.New("bad request: read-only mode: unterminated dollar-quoted string")
}

// isEscapeStringPrefix reports whether the quote at r[i] opens an E'...' escape string.
func isEscapeStringPrefix(r []rune, i int) bool {
if i == 0 || (r[i-1] != 'E' && r[i-1] != 'e') {
return false
}
return i == 1 || !isIdentPart(r[i-2])
}

func isIdentStart(c rune) bool {
return unicode.IsLetter(c) || c == '_'
}

func isIdentPart(c rune) bool {
return unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' || c == '$'
}
Loading
Loading