Skip to content

Commit 88b4855

Browse files
Merge pull request #69 from OpsLevel/add-account-metadata
Add accountMetadata tool
2 parents 9f00df4 + de97cd7 commit 88b4855

File tree

5 files changed

+127
-18
lines changed

5 files changed

+127
-18
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
kind: Added
2+
body: Add an `accountMetadata` tool that allows looking up lifecycles, tiers, levels,
3+
and component types on an account.
4+
time: 2025-07-22T13:39:04.842032-07:00

src/cmd/root.go

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,33 @@ type serializedCampaign struct {
107107
Reminder *opslevel.CampaignReminder
108108
}
109109

110+
// AccountMetadata represents the different types of account metadata that can be fetched
111+
type AccountMetadata string
112+
113+
// Available metadata types
114+
const (
115+
AccountMetadataLifecycles AccountMetadata = "lifecycles"
116+
AccountMetadataLevels AccountMetadata = "levels"
117+
AccountMetadataTiers AccountMetadata = "tiers"
118+
AccountMetadataComponentTypes AccountMetadata = "componentTypes"
119+
)
120+
121+
// AllAccountMetadataStrings returns a slice of all available metadata types as strings
122+
func AllAccountMetadataStrings() []string {
123+
types := []AccountMetadata{
124+
AccountMetadataLifecycles,
125+
AccountMetadataLevels,
126+
AccountMetadataTiers,
127+
AccountMetadataComponentTypes,
128+
}
129+
130+
result := make([]string, len(types))
131+
for i, t := range types {
132+
result[i] = string(t)
133+
}
134+
return result
135+
}
136+
110137
// newToolResult creates a CallToolResult for the passed object handling any json marshaling errors
111138
func newToolResult(obj any, err error) (*mcp.CallToolResult, error) {
112139
if err != nil {
@@ -146,7 +173,7 @@ var rootCmd = &cobra.Command{
146173
s.AddTool(
147174
mcp.NewTool(
148175
"teams",
149-
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."),
176+
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."),
150177
mcp.WithString("searchTerm", mcp.Description("The name of the team to search for. Partial matches are returned. Case insensitive.")),
151178
mcp.WithToolAnnotation(mcp.ToolAnnotation{
152179
Title: "Teams in OpsLevel",
@@ -234,7 +261,7 @@ var rootCmd = &cobra.Command{
234261
),
235262
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
236263
resp, err := client.ListServices(nil)
237-
if err != nil {
264+
if err != nil || resp == nil {
238265
return mcp.NewToolResultErrorFromErr("failed to list components", err), nil
239266
}
240267
var components []serializedComponent
@@ -322,6 +349,84 @@ var rootCmd = &cobra.Command{
322349
return newToolResult(resp.Nodes, err)
323350
})
324351

352+
// Account metadata is lightweight data often only needed to provide context for other tool calls.
353+
// 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.
354+
s.AddTool(
355+
mcp.NewTool(
356+
"accountMetadata",
357+
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."),
358+
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(), ", ")))),
359+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
360+
Title: "Account Metadata in OpsLevel",
361+
ReadOnlyHint: &trueValue,
362+
DestructiveHint: &falseValue,
363+
IdempotentHint: &trueValue,
364+
OpenWorldHint: &trueValue,
365+
}),
366+
),
367+
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
368+
// Get requested types from the arguments
369+
args := req.GetArguments()
370+
var requestedTypes []any
371+
if typesArg, exists := args["types"]; exists && typesArg != nil {
372+
if typesArray, ok := typesArg.([]any); ok {
373+
requestedTypes = typesArray
374+
}
375+
}
376+
fetchAll := len(requestedTypes) == 0
377+
378+
// Convert to a map of AccountMetadata type -> bool for lookups
379+
typesToFetch := make(map[AccountMetadata]bool)
380+
381+
for _, t := range requestedTypes {
382+
if typeStr, ok := t.(string); ok {
383+
typesToFetch[AccountMetadata(typeStr)] = true
384+
}
385+
}
386+
387+
metadata := make(map[string]any)
388+
var fetchErr error
389+
390+
// Fetch lifecycles if requested or fetching all
391+
if fetchAll || typesToFetch[AccountMetadataLifecycles] {
392+
lifecycles, err := client.ListLifecycles()
393+
if err != nil && fetchErr == nil {
394+
fetchErr = fmt.Errorf("failed to list lifecycles: %w", err)
395+
}
396+
metadata[string(AccountMetadataLifecycles)] = lifecycles
397+
}
398+
399+
// Fetch levels if requested or fetching all
400+
if fetchAll || typesToFetch[AccountMetadataLevels] {
401+
levels, err := client.ListLevels(nil)
402+
if err != nil && fetchErr == nil {
403+
fetchErr = fmt.Errorf("failed to list levels: %w", err)
404+
}
405+
metadata[string(AccountMetadataLevels)] = levels.Nodes
406+
}
407+
408+
// Fetch tiers if requested or fetching all
409+
if fetchAll || typesToFetch[AccountMetadataTiers] {
410+
tiers, err := client.ListTiers()
411+
if err != nil && fetchErr == nil {
412+
fetchErr = fmt.Errorf("failed to list tiers: %w", err)
413+
}
414+
metadata[string(AccountMetadataTiers)] = tiers
415+
}
416+
417+
// Fetch component types if requested or fetching all
418+
if fetchAll || typesToFetch[AccountMetadataComponentTypes] {
419+
componentTypes, err := client.ListComponentTypes(nil)
420+
if err != nil && fetchErr == nil {
421+
fetchErr = fmt.Errorf("failed to list component types: %w", err)
422+
}
423+
metadata[string(AccountMetadataComponentTypes)] = componentTypes.Nodes
424+
}
425+
426+
// Return any metadata we could fetch, along with any error
427+
return newToolResult(metadata, fetchErr)
428+
})
429+
325430
// Register ability to fetch a single resource by ID or alias
326431
s.AddTool(
327432
mcp.NewTool(

src/go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ require (
2020
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
2121
github.com/go-playground/locales v0.14.1 // indirect
2222
github.com/go-playground/universal-translator v0.18.1 // indirect
23-
github.com/go-playground/validator/v10 v10.26.0 // indirect
23+
github.com/go-playground/validator/v10 v10.27.0 // indirect
2424
github.com/go-resty/resty/v2 v2.16.5 // indirect
2525
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
2626
github.com/google/go-cmp v0.7.0 // indirect
@@ -47,10 +47,10 @@ require (
4747
github.com/subosito/gotenv v1.6.0 // indirect
4848
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
4949
go.uber.org/multierr v1.11.0 // indirect
50-
golang.org/x/crypto v0.39.0 // indirect
51-
golang.org/x/net v0.41.0 // indirect
52-
golang.org/x/sys v0.33.0 // indirect
53-
golang.org/x/text v0.26.0 // indirect
50+
golang.org/x/crypto v0.40.0 // indirect
51+
golang.org/x/net v0.42.0 // indirect
52+
golang.org/x/sys v0.34.0 // indirect
53+
golang.org/x/text v0.27.0 // indirect
5454
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
5555
gopkg.in/yaml.v3 v3.0.1 // indirect
5656
)

src/go.sum

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
2626
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
2727
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
2828
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
29-
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
30-
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
29+
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
30+
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
3131
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
3232
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
3333
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
119119
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
120120
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
121121
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
122-
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
123-
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
124-
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
125-
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
122+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
123+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
124+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
125+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
126126
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
127127
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
128128
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
129-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
130-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
131-
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
132-
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
129+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
130+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
131+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
132+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
133133
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
134134
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
135135
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

0 commit comments

Comments
 (0)