From de97cd70e3f3780a0dcc893f73fe3b386dfd19f1 Mon Sep 17 00:00:00 2001 From: Derek Etherton Date: Tue, 22 Jul 2025 14:13:05 -0700 Subject: [PATCH] Add accountMetadata tool --- .../unreleased/Added-20250722-133904.yaml | 4 + src/cmd/root.go | 109 +++++++++++++++++- src/go.mod | 10 +- src/go.sum | 20 ++-- src/submodules/opslevel-go | 2 +- 5 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 .changes/unreleased/Added-20250722-133904.yaml diff --git a/.changes/unreleased/Added-20250722-133904.yaml b/.changes/unreleased/Added-20250722-133904.yaml new file mode 100644 index 0000000..58e738e --- /dev/null +++ b/.changes/unreleased/Added-20250722-133904.yaml @@ -0,0 +1,4 @@ +kind: Added +body: Add an `accountMetadata` tool that allows looking up lifecycles, tiers, levels, + and component types on an account. +time: 2025-07-22T13:39:04.842032-07:00 diff --git a/src/cmd/root.go b/src/cmd/root.go index 4a6406f..0930451 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -107,6 +107,33 @@ type serializedCampaign struct { Reminder *opslevel.CampaignReminder } +// AccountMetadata represents the different types of account metadata that can be fetched +type AccountMetadata string + +// Available metadata types +const ( + AccountMetadataLifecycles AccountMetadata = "lifecycles" + AccountMetadataLevels AccountMetadata = "levels" + AccountMetadataTiers AccountMetadata = "tiers" + AccountMetadataComponentTypes AccountMetadata = "componentTypes" +) + +// AllAccountMetadataStrings returns a slice of all available metadata types as strings +func AllAccountMetadataStrings() []string { + types := []AccountMetadata{ + AccountMetadataLifecycles, + AccountMetadataLevels, + AccountMetadataTiers, + AccountMetadataComponentTypes, + } + + result := make([]string, len(types)) + for i, t := range types { + result[i] = string(t) + } + return result +} + // newToolResult creates a CallToolResult for the passed object handling any json marshaling errors func newToolResult(obj any, err error) (*mcp.CallToolResult, error) { if err != nil { @@ -146,7 +173,7 @@ var rootCmd = &cobra.Command{ s.AddTool( mcp.NewTool( "teams", - mcp.WithDescription("Get all the team names, identifiers and metadata for the OpsLevel account. Teams are owners of other objects in OpsLevel. Provide searchTerm when looking for a specific team by name."), + mcp.WithDescription("Get all team names, contact methods, and metadata for the OpsLevel account. Teams are owners of other objects in OpsLevel. Provide searchTerm when looking for a specific team by name."), mcp.WithString("searchTerm", mcp.Description("The name of the team to search for. Partial matches are returned. Case insensitive.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: "Teams in OpsLevel", @@ -234,7 +261,7 @@ var rootCmd = &cobra.Command{ ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { resp, err := client.ListServices(nil) - if err != nil { + if err != nil || resp == nil { return mcp.NewToolResultErrorFromErr("failed to list components", err), nil } var components []serializedComponent @@ -322,6 +349,84 @@ var rootCmd = &cobra.Command{ return newToolResult(resp.Nodes, err) }) + // Account metadata is lightweight data often only needed to provide context for other tool calls. + // We wrap it up in one tool to reduce bloat, but accept a `types` arg to allow the MCP to request what it needs specifically. + s.AddTool( + mcp.NewTool( + "accountMetadata", + mcp.WithDescription("Get metadata about the OpsLevel account including component types, tiers, & lifecycles, and maturity levels. Use this tool to retrieve relevant context (including indexes and ids for filters) before making other tool calls. Provide `types` whenever possible."), + mcp.WithArray("types", mcp.Description(fmt.Sprintf("Optional array of specific metadata types to fetch. Valid values: %s. If omitted, all metadata types will be fetched.", strings.Join(AllAccountMetadataStrings(), ", ")))), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: "Account Metadata in OpsLevel", + ReadOnlyHint: &trueValue, + DestructiveHint: &falseValue, + IdempotentHint: &trueValue, + OpenWorldHint: &trueValue, + }), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Get requested types from the arguments + args := req.GetArguments() + var requestedTypes []any + if typesArg, exists := args["types"]; exists && typesArg != nil { + if typesArray, ok := typesArg.([]any); ok { + requestedTypes = typesArray + } + } + fetchAll := len(requestedTypes) == 0 + + // Convert to a map of AccountMetadata type -> bool for lookups + typesToFetch := make(map[AccountMetadata]bool) + + for _, t := range requestedTypes { + if typeStr, ok := t.(string); ok { + typesToFetch[AccountMetadata(typeStr)] = true + } + } + + metadata := make(map[string]any) + var fetchErr error + + // Fetch lifecycles if requested or fetching all + if fetchAll || typesToFetch[AccountMetadataLifecycles] { + lifecycles, err := client.ListLifecycles() + if err != nil && fetchErr == nil { + fetchErr = fmt.Errorf("failed to list lifecycles: %w", err) + } + metadata[string(AccountMetadataLifecycles)] = lifecycles + } + + // Fetch levels if requested or fetching all + if fetchAll || typesToFetch[AccountMetadataLevels] { + levels, err := client.ListLevels(nil) + if err != nil && fetchErr == nil { + fetchErr = fmt.Errorf("failed to list levels: %w", err) + } + metadata[string(AccountMetadataLevels)] = levels.Nodes + } + + // Fetch tiers if requested or fetching all + if fetchAll || typesToFetch[AccountMetadataTiers] { + tiers, err := client.ListTiers() + if err != nil && fetchErr == nil { + fetchErr = fmt.Errorf("failed to list tiers: %w", err) + } + metadata[string(AccountMetadataTiers)] = tiers + } + + // Fetch component types if requested or fetching all + if fetchAll || typesToFetch[AccountMetadataComponentTypes] { + componentTypes, err := client.ListComponentTypes(nil) + if err != nil && fetchErr == nil { + fetchErr = fmt.Errorf("failed to list component types: %w", err) + } + metadata[string(AccountMetadataComponentTypes)] = componentTypes.Nodes + } + + // Return any metadata we could fetch, along with any error + return newToolResult(metadata, fetchErr) + }) + // Register ability to fetch a single resource by ID or alias s.AddTool( mcp.NewTool( diff --git a/src/go.mod b/src/go.mod index f040924..89a82f6 100644 --- a/src/go.mod +++ b/src/go.mod @@ -20,7 +20,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-resty/resty/v2 v2.16.5 // indirect github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -47,10 +47,10 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/src/go.sum b/src/go.sum index e764dac..56e77f5 100644 --- a/src/go.sum +++ b/src/go.sum @@ -26,8 +26,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= @@ -119,17 +119,17 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/src/submodules/opslevel-go b/src/submodules/opslevel-go index f519f64..68bf49e 160000 --- a/src/submodules/opslevel-go +++ b/src/submodules/opslevel-go @@ -1 +1 @@ -Subproject commit f519f64453e7a837873039fe615836673e0bde4b +Subproject commit 68bf49ef4ec83ebb318f814e0cad83d5a363a568