Skip to content

Commit ff2497d

Browse files
javrudskyHarness
authored andcommitted
feat: [FME-4242]: Added Cloud Cost Management Overview API endpoint (harness#13)
* Using MM/DD/YYY format for dates * Using more idiomatic constants * Adding guideline prompts to allow improving date parameter handling * Rebase with master * [CCM-tools] CCM Overview * [CCM-tools] CCM Overview
1 parent a0f75d3 commit ff2497d

File tree

9 files changed

+360
-0
lines changed

9 files changed

+360
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ Toolset Name: `registries`
4545
- `list_artifacts`: List artifacts in a Harness artifact registry
4646
- `list_registries`: List registries in Harness artifact registry
4747

48+
#### Cloud Cost Management Toolset
49+
50+
Toolset Name: `cloudcostmanagement`
51+
52+
- `get_ccm_overview`: Get the ccm overview of a specific account
4853

4954
#### Logs Toolset
5055

client/cloudcostmanagement.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"github.com/harness/harness-mcp/client/dto"
7+
)
8+
9+
const (
10+
ccmBasePath = "ccm/api"
11+
ccmGetOverviewPath = ccmBasePath + "/overview?accountIdentifier=%s&startTime=%d&endTime=%d&groupBy=%s"
12+
)
13+
14+
type CloudCostManagementService struct {
15+
Client *Client
16+
}
17+
18+
func (c *CloudCostManagementService) GetOverview(ctx context.Context, accID string, startTime int64, endTime int64, groupBy string) (*dto.CEView, error) {
19+
path := fmt.Sprintf(ccmGetOverviewPath, accID, startTime, endTime, groupBy)
20+
params := make(map[string]string)
21+
22+
ccmOverview := new(dto.CEView)
23+
err := c.Client.Get(ctx, path, params, nil, ccmOverview)
24+
if err != nil {
25+
return nil, fmt.Errorf("failed to get ccm overview: %w", err)
26+
}
27+
28+
return ccmOverview, nil
29+
}

client/dto/cloudcostmanagement.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package dto
2+
3+
const (
4+
PeriodTypeHour string = "HOUR"
5+
PeriodTypeDay string = "DAY"
6+
PeriodTypeMonth string = "MONTH"
7+
PeriodTypeWeek string = "WEEK"
8+
PeriodTypeQuarter string = "QUARTER"
9+
PeriodTypeYear string = "YEAR"
10+
)
11+
12+
// CEView represents a ccm overview
13+
type CEView struct {
14+
Status string `json:"state,omitempty"`
15+
Data CCMOverview `json:"data,omitempty"`
16+
CorrelationID string `json:"correlation_id,omitempty"`
17+
}
18+
19+
// CCMOverview represents the Overview data from a CCM Overview
20+
type CCMOverview struct {
21+
CostPerDay []TimeSeriesDataPoints `json:"costPerDay,omitempty"`
22+
TotalCost float64 `json:"totalCost,omitempty"`
23+
TotalCostTrend float64 `json:"totalCostTrend,omitempty"`
24+
RecommendationsCount int32 `json:"recommendationsCount,omitempty"`
25+
}
26+
27+
// TimeSeriesDataPoints represents the data points for a time series
28+
type TimeSeriesDataPoints struct {
29+
Values []CcmDataPoint `json:"values,omitempty"`
30+
Time int64 `json:"time,omitempty"`
31+
}
32+
33+
// CcmDataPoint represents a data point in the time series
34+
type CcmDataPoint struct {
35+
Key CCMReference `json:"key,omitempty"`
36+
Value float64 `json:"value,omitempty"`
37+
}
38+
39+
// CCMReference represents a reference to a CCM entity
40+
type CCMReference struct {
41+
Id string `json:"id,omitempty"`
42+
Name string `json:"name,omitempty"`
43+
Type string `json:"type,omitempty"`
44+
}

cmd/harness-mcp-server/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ func runStdioServer(ctx context.Context, config config.Config) error {
272272
// Register the tools with the server
273273
toolsets.RegisterTools(harnessServer)
274274

275+
// Set the guidelines prompts
276+
harness.RegisterPrompts(harnessServer)
277+
275278
// Create stdio server
276279
stdioServer := server.NewStdioServer(harnessServer)
277280

pkg/harness/cloudcostmanagement.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package harness
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"github.com/harness/harness-mcp/pkg/utils"
8+
"github.com/harness/harness-mcp/client"
9+
"github.com/harness/harness-mcp/client/dto"
10+
"github.com/harness/harness-mcp/cmd/harness-mcp-server/config"
11+
"github.com/mark3labs/mcp-go/mcp"
12+
"github.com/mark3labs/mcp-go/server"
13+
"time"
14+
)
15+
16+
// GetCcmOverview creates a tool for getting a ccm overview from an account
17+
func GetCcmOverviewTool(config *config.Config, client *client.CloudCostManagementService) (tool mcp.Tool, handler server.ToolHandlerFunc) {
18+
now := time.Now()
19+
defaultStartTime := utils.FormatUnixToMMDDYYYY(now.AddDate(0, 0, -60).Unix())
20+
defaultEndTime:= utils.CurrentMMDDYYYY();
21+
return mcp.NewTool("get_ccm_overview",
22+
mcp.WithDescription("Get an overview from an specific account in Harness Cloud Cost Management"),
23+
mcp.WithString("accountIdentifier",
24+
mcp.Description("The account identifier"),
25+
),
26+
mcp.WithString("startTime",
27+
mcp.DefaultString(defaultStartTime),
28+
mcp.Description("Start time of the period in format MM/DD/YYYY. (e.g. 10/30/2025)"),
29+
),
30+
mcp.WithString("endTime",
31+
mcp.DefaultString(defaultEndTime),
32+
mcp.Description("End time of the period in format MM/DD/YYYY. (e.g. 10/30/2025)"),
33+
),
34+
mcp.WithString("groupBy",
35+
mcp.Description("Optional type to group by period"),
36+
mcp.DefaultString(dto.PeriodTypeHour),
37+
mcp.Enum(dto.PeriodTypeHour, dto.PeriodTypeDay, dto.PeriodTypeMonth, dto.PeriodTypeWeek, dto.PeriodTypeQuarter, dto.PeriodTypeYear),
38+
),
39+
WithScope(config, false),
40+
),
41+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
42+
accID, err := OptionalParam[string](request, "accountIdentifier")
43+
if accID == "" {
44+
accID, err = getAccountID(config, request)
45+
}
46+
if err != nil {
47+
return mcp.NewToolResultError(err.Error()), nil
48+
}
49+
50+
startTimeStr, err := requiredParam[string](request, "startTime")
51+
if err != nil {
52+
return mcp.NewToolResultError(err.Error()), nil
53+
}
54+
55+
endTimeStr, err := requiredParam[string](request, "endTime")
56+
if err != nil {
57+
return mcp.NewToolResultError(err.Error()), nil
58+
}
59+
60+
startTime, err := utils.FormatMMDDYYYYToUnixMillis(startTimeStr)
61+
endTime, err := utils.FormatMMDDYYYYToUnixMillis(endTimeStr)
62+
63+
groupBy, err := requiredParam[string](request, "groupBy")
64+
if err != nil {
65+
return mcp.NewToolResultError(err.Error()), nil
66+
}
67+
68+
data, err := client.GetOverview(ctx, accID, startTime, endTime, groupBy)
69+
if err != nil {
70+
return nil, fmt.Errorf("failed to get CCM Overview: %w", err)
71+
}
72+
73+
r, err := json.Marshal(data)
74+
if err != nil {
75+
return nil, fmt.Errorf("failed to marshal CCM Overview: %w", err)
76+
}
77+
78+
return mcp.NewToolResultText(string(r)), nil
79+
}
80+
}
81+
82+
// getAccountID retrieves AccountID from the config file
83+
func getAccountID(config *config.Config, request mcp.CallToolRequest) (string, error) {
84+
scope, scopeErr := fetchScope(config, request, true)
85+
if scopeErr != nil {
86+
return "", nil
87+
}
88+
return scope.AccountID, nil
89+
}

pkg/harness/prompts.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package harness
2+
3+
import (
4+
p "github.com/harness/harness-mcp/pkg/prompts"
5+
"github.com/mark3labs/mcp-go/server"
6+
)
7+
8+
9+
// RegisterPrompts initializes and registers predefined prompts with the MCP server.
10+
func RegisterPrompts(mcpServer *server.MCPServer ) {
11+
prompts := p.Prompts{}
12+
13+
// This prompt is intended to make the LLM handle the date parameters in the correct format because fields descriptions where not enough.
14+
prompts.Append(
15+
p.NewPrompt().SetName("get_ccm_overview").
16+
SetDescription("Ensure parameters are provided correctly and in the right format. ").
17+
SetResultDescription("Input parameters validation").
18+
SetText(`When calling get_ccm_overview, ensure you have: accountIdentifier, groupBy, startDate, and endDate.
19+
- If any are missing, ask the user for the specific value(s).
20+
- Always send startDate and endDate in the following format: 'MM/DD/YYYY' (e.g. '10/30/2025')
21+
- If no dates are supplied, default startDate to 60 days ago and endDate to now.`).
22+
Build())
23+
24+
p.AddPrompts(prompts, mcpServer)
25+
}

pkg/harness/tools.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ func InitToolsets(config *config.Config) (*toolsets.ToolsetGroup, error) {
5555
return nil, err
5656
}
5757

58+
if err := registerCloudCostManagement(config, tsg); err != nil {
59+
return nil, err
60+
}
61+
62+
5863
// Enable requested toolsets
5964
if err := tsg.EnableToolsets(config.Toolsets); err != nil {
6065
return nil, err
@@ -286,3 +291,30 @@ func registerLogs(config *config.Config, tsg *toolsets.ToolsetGroup) error {
286291
tsg.AddToolset(logs)
287292
return nil
288293
}
294+
295+
func registerCloudCostManagement(config *config.Config, tsg *toolsets.ToolsetGroup) error {
296+
// Determine the base URL and secret for CCM
297+
baseURL := config.BaseURL
298+
secret := ""
299+
if config.Internal {
300+
return nil
301+
}
302+
303+
// Create base client for CCM
304+
c, err := createClient(baseURL, config, secret)
305+
if err != nil {
306+
return err
307+
}
308+
309+
ccmClient := &client.CloudCostManagementService{Client: c}
310+
311+
// Create the CCM toolset
312+
ccm := toolsets.NewToolset("ccm", "Harness Cloud Cost Management related tools").
313+
AddReadTools(
314+
toolsets.NewServerTool(GetCcmOverviewTool(config, ccmClient)),
315+
)
316+
317+
// Add toolset to the group
318+
tsg.AddToolset(ccm)
319+
return nil
320+
}

pkg/prompts/promptsregistry.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package prompts
2+
3+
import (
4+
"log/slog"
5+
"context"
6+
"github.com/mark3labs/mcp-go/mcp"
7+
"github.com/mark3labs/mcp-go/server"
8+
)
9+
10+
// harness.prompts is intended to make adding guideline prompts easier by hidding mcp framework details.
11+
// It also wraps the mcp-go framework so that it can be "easily" replaced if necessary.
12+
13+
// Role represents the role of the prompt creator, either User or Assistant.
14+
type Role int
15+
const (
16+
User Role = iota // 0
17+
Assistant // 1
18+
)
19+
20+
// Prompt represents the prompt data needed to add to the MCP server.
21+
type Prompt struct {
22+
Name string
23+
Description string
24+
ResultDescription string
25+
Text string
26+
Role Role
27+
}
28+
29+
// Prompts is a collection of Prompt instances.
30+
type Prompts struct {
31+
Prompts []*Prompt
32+
}
33+
34+
// NewPrompt creates a new Prompt instance with default values.
35+
func NewPrompt() *Prompt {
36+
prompt := &Prompt{}
37+
prompt.Role = User // Default role is User
38+
return prompt
39+
}
40+
41+
// SetName sets the name of the prompt and returns the updated Prompt instance.
42+
func (b *Prompt) SetName(name string) *Prompt {
43+
b.Name = name
44+
return b
45+
}
46+
47+
// SetDescription sets the description of the prompt and returns the updated Prompt instance.
48+
func (b *Prompt) SetDescription(description string) *Prompt {
49+
b.Description = description
50+
return b
51+
}
52+
53+
54+
// SetResultDescription sets the result description of the prompt and returns the updated Prompt instance.
55+
func (b *Prompt) SetResultDescription(resultDescription string) *Prompt {
56+
b.ResultDescription = resultDescription
57+
return b
58+
}
59+
60+
// SetText sets the text content of the prompt and returns the updated Prompt instance.
61+
func (b *Prompt) SetText(text string) *Prompt {
62+
b.Text = text
63+
return b
64+
}
65+
66+
// Build constructs and returns a new Prompt instance based on the current state.
67+
func (b *Prompt) Build() *Prompt {
68+
return &Prompt{
69+
Name: b.Name,
70+
Description: b.Description,
71+
ResultDescription: b.ResultDescription,
72+
Text: b.Text,
73+
Role: b.Role,
74+
}
75+
}
76+
77+
// Append adds a new Prompt to the Prompts collection.
78+
func (p *Prompts) Append(prompt *Prompt) {
79+
p.Prompts = append(p.Prompts, prompt)
80+
}
81+
82+
// GetPrompts retrieves all prompts from the Prompts collection.
83+
func (p *Prompts) GetPrompts() []*Prompt {
84+
return p.Prompts
85+
}
86+
87+
// addPrompts registers a collection of prompts with the MCP server and logs the registration.
88+
func AddPrompts(prompts Prompts, mcpServer *server.MCPServer) {
89+
// Register the prompts with the MCP server
90+
for _, prompt := range prompts.GetPrompts() {
91+
mcpServer.AddPrompt(createPrompt(prompt))
92+
slog.Debug("Registered prompt", "name", prompt.Name, "description", prompt.Description)
93+
}
94+
}
95+
96+
// createPrompt converts a Prompt into an MCP prompt and defines its handler function.
97+
func createPrompt(prompt *Prompt) (mcp.Prompt, server.PromptHandlerFunc) {
98+
role := mcp.RoleUser
99+
if prompt.Role == Assistant {
100+
role = mcp.RoleAssistant
101+
}
102+
103+
return mcp.NewPrompt(prompt.Name, mcp.WithPromptDescription(prompt.Description)),
104+
func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
105+
return mcp.NewGetPromptResult(
106+
prompt.ResultDescription,
107+
[]mcp.PromptMessage {mcp.NewPromptMessage(role, mcp.NewTextContent(prompt.Text))},
108+
), nil
109+
}
110+
}

0 commit comments

Comments
 (0)