Skip to content

Commit 405bf2e

Browse files
David-KreinerHarness
authored andcommitted
feat: [ML-1118]: init genai service and add timeout param for clients (harness#15)
* feat: [ML-1118]: init genai service and add timeout param for clients
1 parent 9b9241a commit 405bf2e

File tree

8 files changed

+367
-9
lines changed

8 files changed

+367
-9
lines changed

client/client.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import (
1313
"time"
1414

1515
"github.com/harness/harness-mcp/pkg/harness/auth"
16+
"github.com/rs/zerolog/log"
1617

1718
"github.com/cenkalti/backoff/v4"
1819
"github.com/harness/harness-mcp/client/dto"
19-
"github.com/rs/zerolog/log"
2020
)
2121

2222
var (
@@ -50,20 +50,25 @@ type service struct {
5050
client *Client
5151
}
5252

53-
func defaultHTTPClient() *http.Client {
53+
func defaultHTTPClient(timeout ...time.Duration) *http.Client {
54+
// Use default timeout of 10 seconds if not specified
55+
clientTimeout := 10 * time.Second
56+
if len(timeout) > 0 {
57+
clientTimeout = timeout[0]
58+
}
5459
return &http.Client{
55-
Timeout: 10 * time.Second,
60+
Timeout: clientTimeout,
5661
}
5762
}
5863

5964
// NewWithToken creates a new client with the specified base URL and API token
60-
func NewWithAuthProvider(uri string, authProvider auth.Provider) (*Client, error) {
65+
func NewWithAuthProvider(uri string, authProvider auth.Provider, timeout ...time.Duration) (*Client, error) {
6166
parsedURL, err := url.Parse(uri)
6267
if err != nil {
6368
return nil, err
6469
}
6570
c := &Client{
66-
client: defaultHTTPClient(),
71+
client: defaultHTTPClient(timeout...),
6772
BaseURL: parsedURL,
6873
AuthProvider: authProvider,
6974
}

client/dto/genai.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package dto
2+
3+
type Capability struct {
4+
Type string `json:"type"`
5+
Version string `json:"version"`
6+
}
7+
8+
type ContextItem struct {
9+
Type string `json:"type"`
10+
Payload any `json:"payload"`
11+
}
12+
13+
type HarnessContext struct {
14+
OrgID string `json:"org_id"`
15+
ProjectID string `json:"project_id"`
16+
AccountID string `json:"account_id"`
17+
}
18+
19+
// RequestAction defines the set of valid actions that can be performed by the genai service
20+
type RequestAction string
21+
22+
// Constants for the various action types
23+
const (
24+
CreateStep RequestAction = "CREATE_STEP"
25+
UpdateStep RequestAction = "UPDATE_STEP"
26+
CreateStage RequestAction = "CREATE_STAGE"
27+
UpdateStage RequestAction = "UPDATE_STAGE"
28+
CreatePipeline RequestAction = "CREATE_PIPELINE"
29+
UpdatePipeline RequestAction = "UPDATE_PIPELINE"
30+
CreateEnv RequestAction = "CREATE_ENVIRONMENT"
31+
UpdateEnv RequestAction = "UPDATE_ENVIRONMENT"
32+
CreateSecret RequestAction = "CREATE_SECRET"
33+
UpdateSecret RequestAction = "UPDATE_SECRET"
34+
CreateService RequestAction = "CREATE_SERVICE"
35+
UpdateService RequestAction = "UPDATE_SERVICE"
36+
CreateConnector RequestAction = "CREATE_CONNECTOR"
37+
UpdateConnector RequestAction = "UPDATE_CONNECTOR"
38+
CreateStepGroup RequestAction = "CREATE_STEP_GROUP"
39+
UpdateStepGroup RequestAction = "UPDATE_STEP_GROUP"
40+
)
41+
42+
type ServiceChatParameters struct {
43+
Prompt string `json:"prompt"`
44+
Provider string `json:"provider,omitempty"`
45+
ModelName string `json:"model_name,omitempty"`
46+
ConversationID string `json:"conversation_id"`
47+
InteractionID string `json:"interaction_id,omitempty"`
48+
Capabilities []Capability `json:"capabilities"`
49+
ConversationRaw []any `json:"conversation_raw,omitempty"`
50+
Context []ContextItem `json:"context,omitempty"`
51+
Action RequestAction `json:"action,omitempty"`
52+
HarnessContext *HarnessContext `json:"harness_context,omitempty"`
53+
Stream bool `json:"stream,omitempty"`
54+
}
55+
56+
type CapabilityToRun struct {
57+
CallID string `json:"call_id"`
58+
Type string `json:"type"`
59+
Input map[string]any `json:"input"`
60+
}
61+
62+
type ServiceChatResponse struct {
63+
ConversationID string `json:"conversation_id"`
64+
ConversationRaw string `json:"conversation_raw"`
65+
CapabilitiesToRun []CapabilityToRun `json:"capabilities_to_run"`
66+
ModelUsage map[string]any `json:"model_usage,omitempty"`
67+
Response string `json:"response,omitempty"`
68+
Error string `json:"error,omitempty"`
69+
}

client/genai.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/harness/harness-mcp/client/dto"
8+
)
9+
10+
const (
11+
aiDevopsChatPath = "chat/platform"
12+
)
13+
14+
type GenaiService struct {
15+
Client *Client
16+
}
17+
18+
func (g *GenaiService) SendAIDevOpsChat(ctx context.Context, scope dto.Scope, request *dto.ServiceChatParameters) (*dto.ServiceChatResponse, error) {
19+
path := aiDevopsChatPath
20+
params := make(map[string]string)
21+
22+
// Only add non-empty scope parameters
23+
if scope.AccountID != "" {
24+
params["accountIdentifier"] = scope.AccountID
25+
}
26+
27+
if scope.OrgID != "" {
28+
params["orgIdentifier"] = scope.OrgID
29+
}
30+
31+
if scope.ProjectID != "" {
32+
params["projectIdentifier"] = scope.ProjectID
33+
}
34+
35+
var response dto.ServiceChatResponse
36+
err := g.Client.Post(ctx, path, params, request, &response)
37+
if err != nil {
38+
return nil, fmt.Errorf("failed to send request to genai service: %w", err)
39+
}
40+
41+
return &response, nil
42+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,7 @@ type Config struct {
2525
NgManagerSecret string
2626
ChatbotBaseURL string
2727
ChatbotSecret string
28+
GenaiBaseURL string
29+
GenaiSecret string
2830
McpSvcSecret string
2931
}

cmd/harness-mcp-server/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ var (
139139
NgManagerSecret: viper.GetString("ng_manager_secret"),
140140
ChatbotBaseURL: viper.GetString("chatbot_base_url"),
141141
ChatbotSecret: viper.GetString("chatbot_secret"),
142+
GenaiBaseURL: viper.GetString("genai_base_url"),
143+
GenaiSecret: viper.GetString("genai_secret"),
142144
McpSvcSecret: viper.GetString("mcp_svc_secret"),
143145
}
144146

@@ -178,6 +180,8 @@ func init() {
178180
internalCmd.Flags().String("ng-manager-secret", "", "Secret for NG manager")
179181
internalCmd.Flags().String("chatbot-base-url", "", "Base URL for chatbot service")
180182
internalCmd.Flags().String("chatbot-secret", "", "Secret for chatbot service")
183+
internalCmd.Flags().String("genai-base-url", "", "Base URL for genai service")
184+
internalCmd.Flags().String("genai-secret", "", "Secret for genai service")
181185
internalCmd.Flags().String("mcp-svc-secret", "", "Secret for MCP service")
182186

183187
// Bind global flags to viper
@@ -200,6 +204,8 @@ func init() {
200204
_ = viper.BindPFlag("ng_manager_secret", internalCmd.Flags().Lookup("ng-manager-secret"))
201205
_ = viper.BindPFlag("chatbot_base_url", internalCmd.Flags().Lookup("chatbot-base-url"))
202206
_ = viper.BindPFlag("chatbot_secret", internalCmd.Flags().Lookup("chatbot-secret"))
207+
_ = viper.BindPFlag("genai_base_url", internalCmd.Flags().Lookup("genai-base-url"))
208+
_ = viper.BindPFlag("genai_secret", internalCmd.Flags().Lookup("genai-secret"))
203209
_ = viper.BindPFlag("mcp_svc_secret", internalCmd.Flags().Lookup("mcp-svc-secret"))
204210

205211
// Add subcommands

pkg/harness/chatbot.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ func AskChatbotTool(config *config.Config, client *client.ChatbotService) (tool
2020
),
2121
mcp.WithArray("chat_history",
2222
mcp.Description("Optional chat history for context"),
23+
mcp.Items(map[string]any{
24+
"type": "object",
25+
"properties": map[string]any{
26+
"question": map[string]any{
27+
"type": "string",
28+
"description": "The question in the chat history",
29+
},
30+
"answer": map[string]any{
31+
"type": "string",
32+
"description": "The answer in the chat history",
33+
},
34+
},
35+
"required": []string{"question", "answer"},
36+
}),
2337
),
2438
WithScope(config, false),
2539
),

pkg/harness/genai.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package harness
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/google/uuid"
9+
10+
"github.com/harness/harness-mcp/client"
11+
"github.com/harness/harness-mcp/client/dto"
12+
"github.com/harness/harness-mcp/cmd/harness-mcp-server/config"
13+
"github.com/mark3labs/mcp-go/mcp"
14+
"github.com/mark3labs/mcp-go/server"
15+
)
16+
17+
func AIDevOpsAgentTool(config *config.Config, client *client.GenaiService) (tool mcp.Tool, handler server.ToolHandlerFunc) {
18+
return mcp.NewTool("ask_ai_devops_agent",
19+
mcp.WithDescription("Send a request to the Harness AI Devops agent to generate harness entities based on the provided action type and prompt."),
20+
mcp.WithString("prompt",
21+
mcp.Required(),
22+
mcp.Description("The prompt to send to the genai service"),
23+
),
24+
mcp.WithString("action",
25+
mcp.Required(),
26+
mcp.Description("The action type to perform (CREATE_STEP, UPDATE_STEP, CREATE_STAGE, etc.)"),
27+
),
28+
mcp.WithString("conversation_id",
29+
mcp.Description("Optional conversation ID to maintain conversation context (if not provided, a new ID will be generated)"),
30+
),
31+
mcp.WithString("interaction_id",
32+
mcp.Description("Optional interaction ID for tracking purposes (if not provided, a new ID will be generated)"),
33+
),
34+
mcp.WithArray("context",
35+
mcp.Description("Optional context information for the request"),
36+
mcp.Items(map[string]any{
37+
"type": "object",
38+
"properties": map[string]any{
39+
"type": map[string]any{
40+
"type": "string",
41+
"description": "The type of context item",
42+
},
43+
"payload": map[string]any{
44+
"description": "The payload for this context item",
45+
},
46+
},
47+
"required": []string{"type", "payload"},
48+
}),
49+
),
50+
mcp.WithArray("conversation_raw",
51+
mcp.Description("Optional conversation history for context"),
52+
mcp.Items(map[string]any{
53+
"type": "object",
54+
"properties": map[string]any{
55+
"role": map[string]any{
56+
"type": "string",
57+
"description": "The role of the message sender (e.g., 'user', 'assistant')",
58+
},
59+
"content": map[string]any{
60+
"type": "string",
61+
"description": "The content of the conversation message",
62+
},
63+
},
64+
"required": []string{"role", "content"},
65+
}),
66+
),
67+
WithScope(config, false),
68+
),
69+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
70+
// Extract required parameters
71+
prompt, err := requiredParam[string](request, "prompt")
72+
if err != nil {
73+
return mcp.NewToolResultError(err.Error()), nil
74+
}
75+
76+
action, err := requiredParam[string](request, "action")
77+
if err != nil {
78+
return mcp.NewToolResultError(err.Error()), nil
79+
}
80+
81+
scope, err := fetchScope(config, request, false)
82+
if err != nil {
83+
return mcp.NewToolResultError(err.Error()), nil
84+
}
85+
86+
// Extract optional parameters
87+
conversationID, _ := OptionalParam[string](request, "conversation_id")
88+
interactionID, _ := OptionalParam[string](request, "interaction_id")
89+
contextRaw, _ := OptionalParam[[]any](request, "context")
90+
conversationRaw, _ := OptionalParam[[]any](request, "conversation_raw")
91+
92+
// Convert context items
93+
var contextItems []dto.ContextItem
94+
for _, ctxRaw := range contextRaw {
95+
if ctxMap, ok := ctxRaw.(map[string]interface{}); ok {
96+
ctxType, _ := ctxMap["type"].(string)
97+
ctxPayload := ctxMap["payload"]
98+
contextItems = append(contextItems, dto.ContextItem{
99+
Type: ctxType,
100+
Payload: ctxPayload,
101+
})
102+
}
103+
}
104+
105+
// Create harness context from scope
106+
harnessContext := &dto.HarnessContext{
107+
AccountID: scope.AccountID,
108+
OrgID: scope.OrgID,
109+
ProjectID: scope.ProjectID,
110+
}
111+
112+
// Generate or use provided IDs
113+
var finalConversationID, finalInteractionID string
114+
115+
if conversationID != "" {
116+
finalConversationID = conversationID
117+
} else {
118+
finalConversationID = uuid.New().String()
119+
}
120+
121+
if interactionID != "" {
122+
finalInteractionID = interactionID
123+
} else {
124+
finalInteractionID = uuid.New().String()
125+
}
126+
127+
// Create genai request
128+
genaiRequest := &dto.ServiceChatParameters{
129+
Prompt: prompt,
130+
ConversationID: finalConversationID,
131+
InteractionID: finalInteractionID,
132+
ConversationRaw: conversationRaw,
133+
Context: contextItems,
134+
Action: dto.RequestAction(strings.ToUpper(action)),
135+
HarnessContext: harnessContext,
136+
Stream: false,
137+
}
138+
139+
// Send the request to the genai service
140+
response, err := client.SendAIDevOpsChat(ctx, scope, genaiRequest)
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to send request to genai service: %w", err)
143+
}
144+
145+
// Check for errors in response
146+
if response.Error != "" {
147+
return mcp.NewToolResultError(response.Error), nil
148+
}
149+
150+
// Format capabilities if present
151+
var capabilitiesText string
152+
if len(response.CapabilitiesToRun) > 0 {
153+
capabilitiesText = "\n\nCapabilities to run:\n"
154+
for _, cap := range response.CapabilitiesToRun {
155+
capabilitiesText += fmt.Sprintf("\n- Type: %s\n CallID: %s", cap.Type, cap.CallID)
156+
157+
// Include input details if available
158+
if len(cap.Input) > 0 {
159+
capabilitiesText += "\n Input:"
160+
for k, v := range cap.Input {
161+
capabilitiesText += fmt.Sprintf("\n %s: %v", k, v)
162+
}
163+
}
164+
capabilitiesText += "\n"
165+
}
166+
}
167+
168+
// Combine any response text with capabilities and usage info
169+
resultText := ""
170+
if response.Response != "" {
171+
resultText = response.Response
172+
} else if response.ConversationRaw != "" {
173+
resultText = response.ConversationRaw
174+
}
175+
176+
// Create the full result text
177+
fullResult := fmt.Sprintf("%s%s", resultText, capabilitiesText)
178+
179+
return mcp.NewToolResultText(fullResult), nil
180+
}
181+
}

0 commit comments

Comments
 (0)