Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions cmd/internal/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ import (
_ "github.com/googleapis/mcp-toolbox/internal/tools/mongodb/mongodbupdatemany"
_ "github.com/googleapis/mcp-toolbox/internal/tools/mongodb/mongodbupdateone"
_ "github.com/googleapis/mcp-toolbox/internal/tools/mssql/mssqlexecutesql"
_ "github.com/googleapis/mcp-toolbox/internal/tools/mssql/mssqllistindexes"
_ "github.com/googleapis/mcp-toolbox/internal/tools/mssql/mssqllisttables"
_ "github.com/googleapis/mcp-toolbox/internal/tools/mssql/mssqlsql"
_ "github.com/googleapis/mcp-toolbox/internal/tools/mysql/mysqlexecutesql"
Expand Down
81 changes: 81 additions & 0 deletions docs/en/integrations/mssql/tools/mssql-list-indexes.md
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
Comment thread
debabsah marked this conversation as resolved.

```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 internal/tools/mssql/mssqllistindexes/mssqllistindexes.go
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 internal/tools/mssql/mssqllistindexes/mssqllistindexes_test.go
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)
}
})
}
}
5 changes: 5 additions & 0 deletions tests/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,11 @@ func AddMSSQLPrebuiltToolConfig(t *testing.T, config map[string]any) map[string]
"source": "my-instance",
"description": "Lists tables in the database.",
}
tools["list_indexes"] = map[string]any{
"type": "mssql-list-indexes",
"source": "my-instance",
"description": "Lists indexes in the database.",
}
config["tools"] = tools
return config
}
Expand Down
1 change: 1 addition & 0 deletions tests/mssql/mssql_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,5 @@ func TestMSSQLToolEndpoints(t *testing.T) {

// Run specific MSSQL tool tests
tests.RunMSSQLListTablesTest(t, tableNameParam, tableNameAuth)
tests.RunMSSQLListIndexesTest(t, tableNameParam)
}
Loading