-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat(mssql): add mssql-list-indexes tool #3508
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
debabsah
wants to merge
1
commit into
googleapis:main
Choose a base branch
from
debabsah:feat-mssql-list-indexes
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| --- | ||
| title: "mssql-list-indexes" | ||
| type: docs | ||
| weight: 1 | ||
| description: > | ||
| The "mssql-list-indexes" tool lists indexes in a SQL Server database. | ||
| --- | ||
|
|
||
| ## About | ||
|
|
||
| The `mssql-list-indexes` tool lists user indexes in a SQL Server database, | ||
| excluding system schemas (`sys`, `INFORMATION_SCHEMA`) and heaps. | ||
|
|
||
| `mssql-list-indexes` lists detailed information as JSON for each index. The tool | ||
| takes the following input parameters: | ||
|
|
||
| - **`schema_name`** (string, optional): A text to filter results by schema name. The input is used within a `LIKE` clause. Default: `""`. | ||
| - **`table_name`** (string, optional): A text to filter results by table name. The input is used within a `LIKE` clause. Default: `""`. | ||
| - **`index_name`** (string, optional): A text to filter results by index name. The input is used within a `LIKE` clause. Default: `""`. | ||
| - **`only_unused`** (boolean, optional): If true, returns only indexes with no recorded reads since the last SQL Server restart. Default: `false`. | ||
| - **`limit`** (integer, optional): The maximum number of rows to return. Default: `50`. | ||
|
|
||
| ## Compatible Sources | ||
|
|
||
| {{< compatible-sources others="integrations/cloud-sql-mssql">}} | ||
|
|
||
| ## Requirements | ||
|
|
||
| The index usage columns (`user_reads`, `user_updates`, `last_user_seek`, | ||
| `last_user_scan`, and `is_used`) are read from `sys.dm_db_index_usage_stats`. | ||
| Querying that dynamic management view requires the `VIEW SERVER STATE` permission | ||
| on SQL Server, or `VIEW DATABASE STATE` on Azure SQL Database. The remaining | ||
| structural columns are read from catalog views, which need no special permission. | ||
|
|
||
| ## Example | ||
|
|
||
| ```yaml | ||
| kind: tool | ||
| name: list_indexes | ||
| type: mssql-list-indexes | ||
| source: mssql-source | ||
| description: | | ||
| Lists user indexes in a SQL Server database, excluding system schemas. For each | ||
| index, the following properties are returned: schema name, table name, index | ||
| name, index type (e.g. CLUSTERED/NONCLUSTERED), whether it is unique, whether it | ||
| backs a primary key, whether it is disabled, the filter definition, the key | ||
| columns, the included columns, the number of user reads (seeks + scans + lookups) | ||
| and user updates recorded by sys.dm_db_index_usage_stats since the last restart, | ||
| and a boolean indicating whether the index has been used at least once. Index | ||
| usage statistics reset on SQL Server restart. | ||
| ``` | ||
|
|
||
| The response is a json array with the following elements: | ||
|
|
||
| ```json | ||
| { | ||
| "schema_name": "schema name", | ||
| "table_name": "table name", | ||
| "index_name": "index name", | ||
| "index_type": "index type (e.g. CLUSTERED, NONCLUSTERED)", | ||
| "is_unique": "boolean indicating if the index is unique", | ||
| "is_primary": "boolean indicating if the index backs a primary key", | ||
| "is_disabled": "boolean indicating if the index is disabled", | ||
| "filter_definition": "filter expression for a filtered index, or null", | ||
| "key_columns": "comma-separated key columns in key order, with ASC/DESC", | ||
| "included_columns": "comma-separated non-key included columns", | ||
| "user_reads": "seeks + scans + lookups recorded since the last restart", | ||
| "user_updates": "updates recorded since the last restart", | ||
| "last_user_seek": "timestamp of the last user seek, or null", | ||
| "last_user_scan": "timestamp of the last user scan, or null", | ||
| "is_used": "boolean indicating if the index has been read at least once" | ||
| } | ||
| ``` | ||
|
|
||
| ## Reference | ||
|
|
||
| | **field** | **type** | **required** | **description** | | ||
| |-------------|:--------:|:------------:|------------------------------------------------------| | ||
| | type | string | true | Must be "mssql-list-indexes". | | ||
| | source | string | true | Name of the source the SQL should execute on. | | ||
| | description | string | false | Description of the tool that is passed to the agent. | | ||
175 changes: 175 additions & 0 deletions
175
internal/tools/mssql/mssqllistindexes/mssqllistindexes.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package mssqllistindexes | ||
|
|
||
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "fmt" | ||
| "net/http" | ||
|
|
||
| yaml "github.com/goccy/go-yaml" | ||
| "github.com/googleapis/mcp-toolbox/internal/tools" | ||
| "github.com/googleapis/mcp-toolbox/internal/util" | ||
| "github.com/googleapis/mcp-toolbox/internal/util/parameters" | ||
| ) | ||
|
|
||
| const resourceType string = "mssql-list-indexes" | ||
|
|
||
| const listIndexesStatement = ` | ||
| WITH IndexDetails AS ( | ||
| SELECT | ||
| s.name AS schema_name, | ||
| t.name AS table_name, | ||
| i.name AS index_name, | ||
| i.type_desc AS index_type, | ||
| i.is_unique, | ||
| i.is_primary_key AS is_primary, | ||
| i.is_disabled, | ||
| i.filter_definition, | ||
| STUFF((SELECT ', ' + c.name + CASE WHEN ic.is_descending_key = 1 THEN ' DESC' ELSE ' ASC' END | ||
| FROM sys.index_columns ic | ||
| JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id | ||
| WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 | ||
| ORDER BY ic.key_ordinal | ||
| FOR XML PATH(''), TYPE).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS key_columns, | ||
| STUFF((SELECT ', ' + c.name | ||
| FROM sys.index_columns ic | ||
| JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id | ||
| WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 1 | ||
| ORDER BY ic.index_column_id | ||
| FOR XML PATH(''), TYPE).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS included_columns, | ||
| ISNULL(us.user_seeks, 0) + ISNULL(us.user_scans, 0) + ISNULL(us.user_lookups, 0) AS user_reads, | ||
| ISNULL(us.user_updates, 0) AS user_updates, | ||
| us.last_user_seek, | ||
| us.last_user_scan, | ||
| CASE | ||
| WHEN (ISNULL(us.user_seeks, 0) + ISNULL(us.user_scans, 0) + ISNULL(us.user_lookups, 0)) > 0 | ||
| THEN CAST(1 AS BIT) ELSE CAST(0 AS BIT) | ||
| END AS is_used | ||
| FROM sys.indexes i | ||
| JOIN sys.tables t ON i.object_id = t.object_id | ||
| JOIN sys.schemas s ON t.schema_id = s.schema_id | ||
| LEFT JOIN sys.dm_db_index_usage_stats us | ||
| ON us.object_id = i.object_id AND us.index_id = i.index_id AND us.database_id = DB_ID() | ||
| WHERE | ||
| t.type = 'U' -- user tables only | ||
| AND i.type <> 0 -- exclude heaps | ||
| AND i.name IS NOT NULL | ||
| AND s.name NOT IN ('sys', 'INFORMATION_SCHEMA') | ||
| ) | ||
| SELECT * | ||
| FROM IndexDetails | ||
| WHERE | ||
| (@schema_name IS NULL OR @schema_name = '' OR schema_name LIKE '%' + @schema_name + '%') | ||
| AND (@table_name IS NULL OR @table_name = '' OR table_name LIKE '%' + @table_name + '%') | ||
| AND (@index_name IS NULL OR @index_name = '' OR index_name LIKE '%' + @index_name + '%') | ||
| AND (@only_unused = 0 OR is_used = 0) | ||
| ORDER BY schema_name, table_name, index_name | ||
| OFFSET 0 ROWS FETCH NEXT @limit ROWS ONLY; | ||
| ` | ||
|
|
||
| func init() { | ||
| if !tools.Register(resourceType, newConfig) { | ||
| panic(fmt.Sprintf("tool type %q already registered", resourceType)) | ||
| } | ||
| } | ||
|
|
||
| func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { | ||
| actual := Config{ConfigBase: tools.ConfigBase{Name: name}} | ||
| if err := decoder.DecodeContext(ctx, &actual); err != nil { | ||
| return nil, err | ||
| } | ||
| return actual, nil | ||
| } | ||
|
|
||
| type compatibleSource interface { | ||
| MSSQLDB() *sql.DB | ||
| RunSQL(context.Context, string, []any) (any, error) | ||
| } | ||
|
|
||
| type Config struct { | ||
| tools.ConfigBase `yaml:",inline"` | ||
| Type string `yaml:"type" validate:"required"` | ||
| Source string `yaml:"source" validate:"required"` | ||
| Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"` | ||
| } | ||
|
|
||
| var _ tools.ToolConfig = Config{} | ||
|
|
||
| func (cfg Config) ToolConfigType() string { | ||
| return resourceType | ||
| } | ||
|
|
||
| func (cfg Config) Initialize(context.Context) (tools.Tool, error) { | ||
| allParameters := parameters.Parameters{ | ||
| parameters.NewStringParameterWithDefault("schema_name", "", "Optional: a text to filter results by schema name. The input is used within a LIKE clause."), | ||
| parameters.NewStringParameterWithDefault("table_name", "", "Optional: a text to filter results by table name. The input is used within a LIKE clause."), | ||
| parameters.NewStringParameterWithDefault("index_name", "", "Optional: a text to filter results by index name. The input is used within a LIKE clause."), | ||
| parameters.NewBooleanParameterWithDefault("only_unused", false, "Optional: If true, only returns indexes with no recorded reads since the last SQL Server restart."), | ||
| parameters.NewIntParameterWithDefault("limit", 50, "Optional: The maximum number of rows to return. Default is 50."), | ||
| } | ||
|
|
||
| if cfg.Description == "" { | ||
| cfg.Description = "Lists user indexes in a SQL Server database, excluding system schemas. For each index returns: schema name, table name, index name, index type (e.g. CLUSTERED/NONCLUSTERED), whether it is unique, whether it backs a primary key, whether it is disabled, the filter definition, the key columns, the included columns, the number of user reads (seeks + scans + lookups) and user updates recorded by sys.dm_db_index_usage_stats since the last restart, and a boolean indicating whether the index has been used at least once. Index usage statistics reset on SQL Server restart." | ||
| } | ||
|
|
||
| return Tool{ | ||
| BaseTool: tools.NewBaseTool( | ||
| cfg, | ||
| tools.GetAnnotationsOrDefault(cfg.Annotations, tools.NewReadOnlyAnnotations), | ||
| tools.Manifest{Description: cfg.Description, Parameters: allParameters.Manifest(), AuthRequired: cfg.AuthRequired}, | ||
| allParameters, | ||
| ), | ||
| }, nil | ||
| } | ||
|
|
||
| var _ tools.Tool = Tool{} | ||
|
|
||
| type Tool struct { | ||
| tools.BaseTool[Config] | ||
| } | ||
|
|
||
| func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, util.ToolboxError) { | ||
| source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Cfg.Source, t.Cfg.Name, t.Cfg.Type) | ||
| if err != nil { | ||
| return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, err) | ||
| } | ||
|
|
||
| paramsMap := params.AsMap() | ||
| namedArgs := []any{ | ||
| sql.Named("schema_name", paramsMap["schema_name"]), | ||
| sql.Named("table_name", paramsMap["table_name"]), | ||
| sql.Named("index_name", paramsMap["index_name"]), | ||
| sql.Named("only_unused", paramsMap["only_unused"]), | ||
| sql.Named("limit", paramsMap["limit"]), | ||
| } | ||
|
|
||
| resp, err := source.RunSQL(ctx, listIndexesStatement, namedArgs) | ||
| if err != nil { | ||
| return nil, util.ProcessGeneralError(err) | ||
| } | ||
|
|
||
| // Return an empty list instead of null when there are no rows. | ||
| resSlice, ok := resp.([]any) | ||
| if !ok || len(resSlice) == 0 { | ||
| return []any{}, nil | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| func (t Tool) ToConfig() tools.ToolConfig { | ||
| return t.Cfg | ||
| } |
95 changes: 95 additions & 0 deletions
95
internal/tools/mssql/mssqllistindexes/mssqllistindexes_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package mssqllistindexes_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| "github.com/googleapis/mcp-toolbox/internal/server" | ||
| "github.com/googleapis/mcp-toolbox/internal/testutils" | ||
| "github.com/googleapis/mcp-toolbox/internal/tools" | ||
| "github.com/googleapis/mcp-toolbox/internal/tools/mssql/mssqllistindexes" | ||
| ) | ||
|
|
||
| func TestParseFromYamlMssqlListIndexes(t *testing.T) { | ||
| ctx, err := testutils.ContextWithNewLogger() | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %s", err) | ||
| } | ||
| tcs := []struct { | ||
| desc string | ||
| in string | ||
| want server.ToolConfigs | ||
| }{ | ||
| { | ||
| desc: "with auth", | ||
| in: ` | ||
| kind: tool | ||
| name: example_tool | ||
| type: mssql-list-indexes | ||
| source: my-mssql-instance | ||
| description: some description | ||
| authRequired: | ||
| - my-google-auth-service | ||
| - other-auth-service | ||
| `, | ||
| want: server.ToolConfigs{ | ||
| "example_tool": mssqllistindexes.Config{ | ||
| ConfigBase: tools.ConfigBase{ | ||
| Name: "example_tool", | ||
| Description: "some description", | ||
| AuthRequired: []string{"my-google-auth-service", "other-auth-service"}, | ||
| }, | ||
| Type: "mssql-list-indexes", | ||
| Source: "my-mssql-instance", | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| desc: "without auth", | ||
| in: ` | ||
| kind: tool | ||
| name: example_tool | ||
| type: mssql-list-indexes | ||
| source: my-mssql-instance | ||
| description: some description | ||
| `, | ||
| want: server.ToolConfigs{ | ||
| "example_tool": mssqllistindexes.Config{ | ||
| ConfigBase: tools.ConfigBase{ | ||
| Name: "example_tool", | ||
| Description: "some description", | ||
| AuthRequired: []string{}, | ||
| }, | ||
| Type: "mssql-list-indexes", | ||
| Source: "my-mssql-instance", | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| for _, tc := range tcs { | ||
| t.Run(tc.desc, func(t *testing.T) { | ||
| // Parse contents | ||
| _, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) | ||
| if err != nil { | ||
| t.Fatalf("unable to unmarshal: %s", err) | ||
| } | ||
| if diff := cmp.Diff(tc.want, got); diff != "" { | ||
| t.Fatalf("incorrect parse: diff %v", diff) | ||
| } | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.