Skip to content

Commit a5b3569

Browse files
hingerabhinavHarness
authored andcommitted
feat: [ML-1174]: Add "List Template" tool added to mcp server (#35)
* Merge remote-tracking branch 'origin' into hinger-templates-ML-1174 * refactor: move template response formatting logic to dto package with human-readable dates * add single template tool * remove org/project/acc tools for template * update README.md * fix external paths * feat: [ML-1174]: template tools added to mcp server
1 parent d92480a commit a5b3569

File tree

8 files changed

+400
-0
lines changed

8 files changed

+400
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ Toolset Name: `logs`
106106

107107
- `download_execution_logs`: Download logs for a pipeline execution
108108

109+
#### Templates Toolset
110+
111+
Toolset Name: `templates`
112+
113+
- `list_templates`: List templates at a given scope
109114

110115
#### Internal Developer Portal Toolset
111116

client/dto/epochutils.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dto
2+
3+
import (
4+
"time"
5+
)
6+
7+
// FormatUnixMillisToRFC3339 converts Unix timestamp in milliseconds to RFC3339 format
8+
func FormatUnixMillisToRFC3339(ms int64) string {
9+
if ms <= 0 {
10+
return ""
11+
}
12+
// Convert milliseconds to seconds and nanoseconds for Unix time
13+
sec := ms / 1000
14+
nsec := (ms % 1000) * 1000000
15+
t := time.Unix(sec, nsec)
16+
return t.Format(time.RFC3339)
17+
}
18+
19+
// FormatUnixMillisToMMDDYYYY converts Unix timestamp in milliseconds to MM/DD/YYYY format
20+
func FormatUnixMillisToMMDDYYYY(ms int64) string {
21+
if ms <= 0 {
22+
return ""
23+
}
24+
// Convert milliseconds to seconds and nanoseconds for Unix time
25+
sec := ms / 1000
26+
nsec := (ms % 1000) * 1000000
27+
t := time.Unix(sec, nsec)
28+
return t.Format("01/02/2006")
29+
}

client/dto/template.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package dto
2+
3+
// TemplateListOptions represents options for listing templates
4+
type TemplateListOptions struct {
5+
SearchTerm string `json:"search_term,omitempty"`
6+
TemplateListType string `json:"template_list_type,omitempty"`
7+
PaginationOptions
8+
}
9+
10+
// TemplateMetadataSummaryResponse represents a template metadata summary
11+
type TemplateMetadataSummaryResponse struct {
12+
Account string `json:"account,omitempty"`
13+
Org string `json:"org,omitempty"`
14+
Project string `json:"project,omitempty"`
15+
Identifier string `json:"identifier,omitempty"`
16+
Name string `json:"name,omitempty"`
17+
Description string `json:"description,omitempty"`
18+
Tags map[string]string `json:"tags,omitempty"`
19+
VersionLabel string `json:"version_label,omitempty"`
20+
EntityType string `json:"entity_type,omitempty"`
21+
ChildType string `json:"child_type,omitempty"`
22+
Scope string `json:"scope,omitempty"`
23+
Version int64 `json:"version,omitempty"`
24+
GitDetails *EntityGitDetails `json:"git_details,omitempty"`
25+
Updated int64 `json:"updated,omitempty"`
26+
StoreType string `json:"store_type,omitempty"`
27+
ConnectorRef string `json:"connector_ref,omitempty"`
28+
StableTemplate bool `json:"stable_template,omitempty"`
29+
}
30+
31+
// EntityGitDetails represents git details for an entity
32+
type EntityGitDetails struct {
33+
ObjectID string `json:"object_id,omitempty"`
34+
BranchName string `json:"branch_name,omitempty"`
35+
FilePath string `json:"file_path,omitempty"`
36+
RepoName string `json:"repo_name,omitempty"`
37+
CommitID string `json:"commit_id,omitempty"`
38+
FileURL string `json:"file_url,omitempty"`
39+
RepoURL string `json:"repo_url,omitempty"`
40+
}
41+
42+
// TemplateMetaDataList represents a list of template metadata
43+
type TemplateMetaDataList []TemplateMetadataSummaryResponse
44+
45+
// TemplateOutput is a custom struct for template output with numeric timestamps
46+
type TemplateOutput struct {
47+
Account string `json:"account,omitempty"`
48+
Org string `json:"org,omitempty"`
49+
Project string `json:"project,omitempty"`
50+
Identifier string `json:"identifier,omitempty"`
51+
Name string `json:"name,omitempty"`
52+
Description string `json:"description,omitempty"`
53+
Tags map[string]string `json:"tags,omitempty"`
54+
VersionLabel string `json:"version_label,omitempty"`
55+
EntityType string `json:"entity_type,omitempty"`
56+
ChildType string `json:"child_type,omitempty"`
57+
Scope string `json:"scope,omitempty"`
58+
Version int64 `json:"version,omitempty"`
59+
GitDetails *EntityGitDetails `json:"git_details,omitempty"`
60+
Updated string `json:"updated,omitempty"`
61+
StoreType string `json:"store_type,omitempty"`
62+
ConnectorRef string `json:"connector_ref,omitempty"`
63+
StableTemplate bool `json:"stable_template,omitempty"`
64+
}
65+
66+
// TemplateListOutput is a custom struct for template list output with numeric timestamps
67+
type TemplateListOutput struct {
68+
Templates []TemplateOutput `json:"templates"`
69+
}
70+
71+
// ToTemplateResponse processes the raw API response and adds human-readable timestamps
72+
func ToTemplateResponse(data *TemplateMetaDataList) *TemplateListOutput {
73+
if data == nil {
74+
return &TemplateListOutput{Templates: []TemplateOutput{}}
75+
}
76+
77+
result := &TemplateListOutput{
78+
Templates: make([]TemplateOutput, len(*data)),
79+
}
80+
81+
for i, template := range *data {
82+
// Copy all fields
83+
output := TemplateOutput{
84+
Account: template.Account,
85+
Org: template.Org,
86+
Project: template.Project,
87+
Identifier: template.Identifier,
88+
Name: template.Name,
89+
Description: template.Description,
90+
Tags: template.Tags,
91+
VersionLabel: template.VersionLabel,
92+
EntityType: template.EntityType,
93+
ChildType: template.ChildType,
94+
Scope: template.Scope,
95+
Version: template.Version,
96+
GitDetails: template.GitDetails,
97+
Updated: FormatUnixMillisToMMDDYYYY(template.Updated),
98+
StoreType: template.StoreType,
99+
ConnectorRef: template.ConnectorRef,
100+
StableTemplate: template.StableTemplate,
101+
}
102+
103+
result.Templates[i] = output
104+
}
105+
106+
return result
107+
}

client/templates.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/harness/harness-mcp/client/dto"
8+
)
9+
10+
const (
11+
// Base API paths
12+
templateAccountPath = "v1/templates"
13+
templateOrgPath = "v1/orgs/%s/templates"
14+
templateProjectPath = "v1/orgs/%s/projects/%s/templates"
15+
)
16+
17+
type TemplateService struct {
18+
Client *Client
19+
UseInternalPaths bool
20+
}
21+
22+
func (ts *TemplateService) buildPath(basePath string) string {
23+
return basePath
24+
}
25+
26+
// ListAccount lists templates in the account scope
27+
func (ts *TemplateService) ListAccount(ctx context.Context, opts *dto.TemplateListOptions) (*dto.TemplateMetaDataList, error) {
28+
endpoint := ts.buildPath(templateAccountPath)
29+
30+
params := make(map[string]string)
31+
if opts.SearchTerm != "" {
32+
params["searchTerm"] = opts.SearchTerm
33+
}
34+
if opts.TemplateListType != "" {
35+
params["templateListType"] = opts.TemplateListType
36+
}
37+
params["page"] = fmt.Sprintf("%d", opts.Page)
38+
params["size"] = fmt.Sprintf("%d", opts.Size)
39+
40+
var result dto.TemplateMetaDataList
41+
err := ts.Client.Get(ctx, endpoint, params, map[string]string{}, &result)
42+
if err != nil {
43+
return nil, fmt.Errorf("failed to list account templates: %w", err)
44+
}
45+
46+
return &result, nil
47+
}
48+
49+
// ListOrg lists templates in the organization scope
50+
func (ts *TemplateService) ListOrg(ctx context.Context, scope dto.Scope, opts *dto.TemplateListOptions) (*dto.TemplateMetaDataList, error) {
51+
endpoint := ts.buildPath(fmt.Sprintf(templateOrgPath, scope.OrgID))
52+
53+
params := make(map[string]string)
54+
addScope(scope, params)
55+
if opts.SearchTerm != "" {
56+
params["searchTerm"] = opts.SearchTerm
57+
}
58+
if opts.TemplateListType != "" {
59+
params["templateListType"] = opts.TemplateListType
60+
}
61+
params["page"] = fmt.Sprintf("%d", opts.Page)
62+
params["size"] = fmt.Sprintf("%d", opts.Size)
63+
64+
var result dto.TemplateMetaDataList
65+
err := ts.Client.Get(ctx, endpoint, params, map[string]string{}, &result)
66+
if err != nil {
67+
return nil, fmt.Errorf("failed to list org templates: %w", err)
68+
}
69+
70+
return &result, nil
71+
}
72+
73+
// ListProject lists templates in the project scope
74+
func (ts *TemplateService) ListProject(ctx context.Context, scope dto.Scope, opts *dto.TemplateListOptions) (*dto.TemplateMetaDataList, error) {
75+
endpoint := ts.buildPath(fmt.Sprintf(templateProjectPath, scope.OrgID, scope.ProjectID))
76+
77+
params := make(map[string]string)
78+
addScope(scope, params)
79+
if opts.SearchTerm != "" {
80+
params["searchTerm"] = opts.SearchTerm
81+
}
82+
if opts.TemplateListType != "" {
83+
params["templateListType"] = opts.TemplateListType
84+
}
85+
params["page"] = fmt.Sprintf("%d", opts.Page)
86+
params["size"] = fmt.Sprintf("%d", opts.Size)
87+
88+
var result dto.TemplateMetaDataList
89+
err := ts.Client.Get(ctx, endpoint, params, map[string]string{}, &result)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to list project templates: %w", err)
92+
}
93+
94+
return &result, nil
95+
}

cmd/harness-mcp-server/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ type Config struct {
3636
McpSvcSecret string
3737
ChaosManagerSvcBaseURL string
3838
ChaosManagerSvcSecret string
39+
TemplateSvcBaseURL string
40+
TemplateSvcSecret string
3941
CodeSvcBaseURL string
4042
CodeSvcSecret string
4143
LogSvcBaseURL string

cmd/harness-mcp-server/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ var (
150150
McpSvcSecret: viper.GetString("mcp_svc_secret"),
151151
ChaosManagerSvcBaseURL: viper.GetString("chaos_manager_svc_base_url"),
152152
ChaosManagerSvcSecret: viper.GetString("chaos_manager_svc_secret"),
153+
TemplateSvcBaseURL: viper.GetString("template_svc_base_url"),
154+
TemplateSvcSecret: viper.GetString("template_svc_secret"),
153155
CodeSvcBaseURL: viper.GetString("code_svc_base_url"),
154156
CodeSvcSecret: viper.GetString("code_svc_secret"),
155157
LogSvcBaseURL: viper.GetString("log_svc_base_url"),
@@ -235,6 +237,8 @@ func init() {
235237
_ = viper.BindPFlag("artifact_registry_secret", internalCmd.Flags().Lookup("artifact-registry-secret"))
236238
_ = viper.BindPFlag("nextgen_ce_base_url", internalCmd.Flags().Lookup("nextgen-ce-base-url"))
237239
_ = viper.BindPFlag("nextgen_ce_secret", internalCmd.Flags().Lookup("nextgen-ce-secret"))
240+
_ = viper.BindPFlag("template_svc_base_url", internalCmd.Flags().Lookup("template-svc-base-url"))
241+
_ = viper.BindPFlag("template_svc_secret", internalCmd.Flags().Lookup("template-svc-secret"))
238242
_ = viper.BindPFlag("chaos_manager_svc_base_url", internalCmd.Flags().Lookup("chaos-manager-svc-base-url"))
239243
_ = viper.BindPFlag("chaos_manager_svc_secret", internalCmd.Flags().Lookup("chaos-manager-svc-secret"))
240244
_ = viper.BindPFlag("code_svc_base_url", internalCmd.Flags().Lookup("code-svc-base-url"))

pkg/harness/templates.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package harness
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
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+
)
14+
15+
// ListTemplates creates a tool that allows querying templates at all scopes (account, org, project)
16+
// depending on the input parameters.
17+
func ListTemplates(config *config.Config, client *client.TemplateService) (tool mcp.Tool, handler server.ToolHandlerFunc) {
18+
return mcp.NewTool("list_templates",
19+
mcp.WithDescription("List templates at account, organization, or project scope depending on the provided parameters."),
20+
mcp.WithString("search_term",
21+
mcp.Description("Optional search term to filter templates"),
22+
),
23+
mcp.WithString("template_list_type",
24+
mcp.Description("Type of templates to list (e.g., Step, Stage, Pipeline)"),
25+
),
26+
mcp.WithString("scope",
27+
mcp.Description("Scope level to query templates from (account, org, project). If not specified, defaults to project if org_id and project_id are provided, org if only org_id is provided, or account otherwise."),
28+
mcp.Enum("account", "org", "project"),
29+
),
30+
WithScope(config, false),
31+
WithPagination(),
32+
),
33+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
34+
// Fetch pagination parameters
35+
page, size, err := fetchPagination(request)
36+
if err != nil {
37+
return mcp.NewToolResultError(err.Error()), nil
38+
}
39+
40+
// Fetch optional parameters
41+
searchTerm, err := OptionalParam[string](request, "search_term")
42+
if err != nil {
43+
return mcp.NewToolResultError(err.Error()), nil
44+
}
45+
46+
templateListType, err := OptionalParam[string](request, "template_list_type")
47+
if err != nil {
48+
return mcp.NewToolResultError(err.Error()), nil
49+
}
50+
51+
// Create options object
52+
opts := &dto.TemplateListOptions{
53+
SearchTerm: searchTerm,
54+
TemplateListType: templateListType,
55+
PaginationOptions: dto.PaginationOptions{
56+
Page: page,
57+
Size: size,
58+
},
59+
}
60+
61+
// Determine scope from parameters
62+
scopeParam, err := OptionalParam[string](request, "scope")
63+
if err != nil {
64+
return mcp.NewToolResultError(err.Error()), nil
65+
}
66+
67+
// Try to fetch scope parameters (org_id, project_id) if provided
68+
scope, err := fetchScope(config, request, false)
69+
if err != nil {
70+
return mcp.NewToolResultError(err.Error()), nil
71+
}
72+
73+
// If scope is not explicitly specified, determine it from available parameters
74+
if scopeParam == "" {
75+
if scope.ProjectID != "" && scope.OrgID != "" {
76+
scopeParam = "project"
77+
} else if scope.OrgID != "" {
78+
scopeParam = "org"
79+
} else {
80+
scopeParam = "account"
81+
}
82+
}
83+
84+
// Call appropriate API based on scope
85+
var data *dto.TemplateMetaDataList
86+
switch scopeParam {
87+
case "account":
88+
data, err = client.ListAccount(ctx, opts)
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to list account templates: %w", err)
91+
}
92+
case "org":
93+
if scope.OrgID == "" {
94+
return mcp.NewToolResultError("org_id is required for org scope"), nil
95+
}
96+
data, err = client.ListOrg(ctx, scope, opts)
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to list org templates: %w", err)
99+
}
100+
case "project":
101+
if scope.OrgID == "" || scope.ProjectID == "" {
102+
return mcp.NewToolResultError("org_id and project_id are required for project scope"), nil
103+
}
104+
data, err = client.ListProject(ctx, scope, opts)
105+
if err != nil {
106+
return nil, fmt.Errorf("failed to list project templates: %w", err)
107+
}
108+
default:
109+
return mcp.NewToolResultError(fmt.Sprintf("invalid scope: %s", scopeParam)), nil
110+
}
111+
112+
// Create a new TemplateListOutput from the data
113+
templateListOutput := dto.ToTemplateResponse(data)
114+
115+
// Marshal and return the result
116+
r, err := json.Marshal(templateListOutput)
117+
if err != nil {
118+
return nil, fmt.Errorf("failed to marshal template list: %w", err)
119+
}
120+
121+
return mcp.NewToolResultText(string(r)), nil
122+
}
123+
}

0 commit comments

Comments
 (0)