Skip to content

Commit 90f33db

Browse files
author
Marek Safarik
committed
Web GUI (slow without streaming)
Signed-off-by: Marek Safarik <[email protected]>
1 parent bacb6a3 commit 90f33db

File tree

12 files changed

+649
-295
lines changed

12 files changed

+649
-295
lines changed

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ clean:
4444

4545
mcp:
4646
cd backend && go build -o server *.go
47+
48+
web:
49+
cd mcp-client && go run *.go --web

mcp-client/agent.go

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log"
8+
"os/exec"
9+
"strings"
10+
11+
"github.com/anthropics/anthropic-sdk-go"
12+
"github.com/anthropics/anthropic-sdk-go/option"
13+
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
)
15+
16+
var (
17+
ErrMissingAPIKey = errors.New("ANTHROPIC_API_KEY environment variable not set")
18+
ErrServerNotFound = errors.New("MCP server binary not found")
19+
)
20+
21+
type OutputHandler func(message string)
22+
23+
type Agent struct {
24+
config *Config
25+
session *mcp.ClientSession
26+
cmd *exec.Cmd
27+
anthropicClient anthropic.Client
28+
claudeTools []anthropic.ToolUnionParam
29+
outputHandler OutputHandler
30+
}
31+
32+
func NewAgent(cfg *Config) *Agent {
33+
return &Agent{
34+
config: cfg,
35+
outputHandler: defaultOutputHandler,
36+
}
37+
}
38+
39+
func (a *Agent) SetOutputHandler(handler OutputHandler) {
40+
a.outputHandler = handler
41+
}
42+
43+
func (a *Agent) output(message string) {
44+
if a.outputHandler != nil {
45+
a.outputHandler(message)
46+
}
47+
}
48+
49+
func defaultOutputHandler(message string) {
50+
fmt.Println(message)
51+
}
52+
53+
func (a *Agent) Initialize(ctx context.Context) error {
54+
if err := a.connectToMCPServer(ctx); err != nil {
55+
return fmt.Errorf("failed to connect to MCP server: %w", err)
56+
}
57+
58+
if err := a.loadMCPTools(ctx); err != nil {
59+
return fmt.Errorf("failed to load MCP tools: %w", err)
60+
}
61+
62+
a.anthropicClient = anthropic.NewClient(option.WithAPIKey(a.config.AnthropicAPIKey))
63+
64+
return nil
65+
}
66+
67+
func (a *Agent) Close() {
68+
if a.session != nil {
69+
a.session.Close()
70+
}
71+
if a.cmd != nil && a.cmd.Process != nil {
72+
a.cmd.Process.Kill()
73+
}
74+
}
75+
76+
func (a *Agent) connectToMCPServer(ctx context.Context) error {
77+
client := mcp.NewClient(&mcp.Implementation{
78+
Name: mcpClientName,
79+
Version: mcpClientVersion,
80+
}, nil)
81+
82+
cmd := exec.Command(a.config.MCPServerPath)
83+
transport := &mcp.CommandTransport{Command: cmd}
84+
session, err := client.Connect(ctx, transport, nil)
85+
if err != nil {
86+
return fmt.Errorf("failed to connect: %w", err)
87+
}
88+
89+
a.session = session
90+
a.cmd = cmd
91+
92+
a.monitorServerProcess(ctx)
93+
94+
log.Printf("Connected to MCP server: %s", a.config.MCPServerPath)
95+
return nil
96+
}
97+
98+
func (a *Agent) monitorServerProcess(ctx context.Context) {
99+
if a.cmd.Process != nil {
100+
go func() {
101+
state, waitErr := a.cmd.Process.Wait()
102+
if ctx.Err() != nil {
103+
return
104+
}
105+
if waitErr != nil {
106+
log.Printf("[Warning] MCP server process monitoring failed: %v", waitErr)
107+
} else if !state.Success() {
108+
log.Printf("[Error] MCP server process exited unexpectedly with status: %v", state)
109+
} else {
110+
log.Printf("[Info] MCP server process exited normally")
111+
}
112+
}()
113+
}
114+
}
115+
116+
func (a *Agent) loadMCPTools(ctx context.Context) error {
117+
tools, err := a.session.ListTools(ctx, &mcp.ListToolsParams{})
118+
if err != nil {
119+
return fmt.Errorf("failed to list tools: %w", err)
120+
}
121+
122+
a.claudeTools = make([]anthropic.ToolUnionParam, 0, len(tools.Tools))
123+
for _, tool := range tools.Tools {
124+
claudeTool := a.convertMCPToolToClaudeTool(tool)
125+
a.claudeTools = append(a.claudeTools, claudeTool)
126+
}
127+
128+
return nil
129+
}
130+
131+
func (a *Agent) convertMCPToolToClaudeTool(tool *mcp.Tool) anthropic.ToolUnionParam {
132+
inputSchemaMap, ok := tool.InputSchema.(map[string]any)
133+
if !ok || inputSchemaMap == nil {
134+
inputSchemaMap = map[string]any{}
135+
}
136+
137+
var properties any
138+
if p, ok := inputSchemaMap["properties"].(map[string]any); ok && p != nil {
139+
properties = p
140+
} else {
141+
properties = map[string]any{}
142+
}
143+
144+
var required []string
145+
if r, ok := inputSchemaMap["required"].([]interface{}); ok {
146+
for _, v := range r {
147+
if s, ok := v.(string); ok {
148+
required = append(required, s)
149+
}
150+
}
151+
}
152+
153+
toolParam := anthropic.ToolUnionParamOfTool(
154+
anthropic.ToolInputSchemaParam{
155+
Type: "object",
156+
Properties: properties,
157+
Required: required,
158+
},
159+
tool.Name,
160+
)
161+
162+
toolParam.OfTool.Description = anthropic.String(tool.Description)
163+
return toolParam
164+
}
165+
166+
func (a *Agent) ProcessQuery(ctx context.Context, query string) error {
167+
messages := []anthropic.MessageParam{
168+
anthropic.NewUserMessage(anthropic.NewTextBlock(query)),
169+
}
170+
171+
for _ = range maxAgentTurns {
172+
message, err := a.anthropicClient.Messages.New(ctx, anthropic.MessageNewParams{
173+
Model: claudeModel,
174+
MaxTokens: maxTokens,
175+
System: []anthropic.TextBlockParam{{Type: "text", Text: systemPrompt}},
176+
Messages: messages,
177+
Tools: a.claudeTools,
178+
})
179+
if err != nil {
180+
return fmt.Errorf("claude API error: %w", err)
181+
}
182+
183+
assistantContent, toolResults, hasToolUse := a.processClaudeResponse(ctx, message)
184+
185+
if !hasToolUse {
186+
return nil
187+
}
188+
189+
messages = append(messages, anthropic.NewAssistantMessage(assistantContent...))
190+
messages = append(messages, anthropic.NewUserMessage(toolResults...))
191+
}
192+
193+
a.output("\n=== Maximum turns reached, requesting summary ===")
194+
a.generateFinalSummary(ctx, messages)
195+
196+
return nil
197+
}
198+
199+
func (a *Agent) processClaudeResponse(
200+
ctx context.Context,
201+
message *anthropic.Message,
202+
) (
203+
assistantContent []anthropic.ContentBlockParamUnion,
204+
toolResults []anthropic.ContentBlockParamUnion,
205+
hasToolUse bool,
206+
) {
207+
assistantContent = []anthropic.ContentBlockParamUnion{}
208+
toolResults = []anthropic.ContentBlockParamUnion{}
209+
210+
for _, block := range message.Content {
211+
switch block := block.AsAny().(type) {
212+
case anthropic.TextBlock:
213+
a.output(block.Text)
214+
assistantContent = append(assistantContent, anthropic.NewTextBlock(block.Text))
215+
216+
case anthropic.ToolUseBlock:
217+
hasToolUse = true
218+
log.Printf("\n[Tool Use] %s", block.Name)
219+
220+
assistantContent = append(assistantContent,
221+
anthropic.NewToolUseBlock(block.ID, block.Input, block.Name))
222+
223+
toolResult := a.executeToolCall(ctx, block)
224+
toolResults = append(toolResults, toolResult)
225+
}
226+
}
227+
228+
return assistantContent, toolResults, hasToolUse
229+
}
230+
231+
func (a *Agent) executeToolCall(
232+
ctx context.Context,
233+
toolUse anthropic.ToolUseBlock,
234+
) anthropic.ContentBlockParamUnion {
235+
result, err := a.session.CallTool(ctx, &mcp.CallToolParams{
236+
Name: toolUse.Name,
237+
Arguments: toolUse.Input,
238+
})
239+
240+
if err != nil {
241+
log.Printf("[Error] CallTool failed: %v", err)
242+
return anthropic.NewToolResultBlock(
243+
toolUse.ID,
244+
fmt.Sprintf("Error: %v", err),
245+
true,
246+
)
247+
}
248+
249+
if result.IsError {
250+
errorDetails := extractTextContent(result.Content)
251+
log.Printf("[Error] Tool execution failed for tool '%s': %s", toolUse.Name, errorDetails)
252+
return anthropic.NewToolResultBlock(
253+
toolUse.ID,
254+
fmt.Sprintf("Tool '%s' execution failed: %s", toolUse.Name, errorDetails),
255+
true,
256+
)
257+
}
258+
259+
resultText := extractTextContent(result.Content)
260+
if resultText == "" {
261+
log.Printf("[Warning] Tool returned empty content - this might indicate an unexpected response from MCP server")
262+
}
263+
log.Printf("================================================")
264+
log.Printf("[Tool Result]\n%s", resultText)
265+
log.Printf("================================================")
266+
267+
return anthropic.NewToolResultBlock(toolUse.ID, resultText, false)
268+
}
269+
270+
func (a *Agent) generateFinalSummary(ctx context.Context, messages []anthropic.MessageParam) {
271+
summaryPrompt := `I've reached the maximum number of allowed turns. Please provide a summary of:
272+
1. What you accomplished so far
273+
2. What still needs to be done
274+
3. Any issues or blockers encountered`
275+
276+
messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(summaryPrompt)))
277+
278+
finalMsg, err := a.anthropicClient.Messages.New(ctx, anthropic.MessageNewParams{
279+
Model: claudeModel,
280+
MaxTokens: maxTokens,
281+
System: []anthropic.TextBlockParam{{Type: "text", Text: systemPrompt}},
282+
Messages: messages,
283+
})
284+
if err != nil {
285+
log.Printf("failed to get final summary: %v", err)
286+
return
287+
}
288+
289+
for _, block := range finalMsg.Content {
290+
if textBlock, ok := block.AsAny().(anthropic.TextBlock); ok {
291+
a.output(textBlock.Text)
292+
}
293+
}
294+
}
295+
296+
func extractTextContent(content []mcp.Content) string {
297+
var resultText strings.Builder
298+
299+
for _, c := range content {
300+
if textContent, ok := c.(*mcp.TextContent); ok {
301+
resultText.WriteString(textContent.Text)
302+
}
303+
}
304+
305+
return resultText.String()
306+
}

mcp-client/config.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package main
2+
3+
import "github.com/anthropics/anthropic-sdk-go"
4+
5+
const (
6+
defaultServerPath = "../backend/server"
7+
mcpClientName = "mcp-client"
8+
mcpClientVersion = "v1.0.0"
9+
10+
claudeModel = anthropic.ModelClaude3_5HaikuLatest
11+
maxTokens = 2048
12+
maxAgentTurns = 5
13+
14+
systemPrompt = `You are an AI assistant with access to Keylime system management tools. Your goal is to help users manage and monitor their Keylime infrastructure.
15+
16+
You have a maximum of 5 conversation turns to complete the task. When given a task:
17+
1. Break it down into steps if needed
18+
2. Use available tools to gather information and take actions
19+
3. Chain multiple tool calls together to accomplish complex tasks
20+
4. Provide clear explanations of what you're doing and what you found
21+
5. If you encounter failures, investigate and suggest solutions
22+
6. Work efficiently to complete tasks within the turn limit`
23+
)
24+
25+
type Config struct {
26+
AnthropicAPIKey string
27+
MCPServerPath string
28+
}
29+
30+
func NewConfig() (*Config, error) {
31+
apiKey := getEnvOrDefault("ANTHROPIC_API_KEY", "")
32+
if apiKey == "" {
33+
return nil, ErrMissingAPIKey
34+
}
35+
36+
serverPath := getEnvOrDefault("MCP_SERVER_PATH", defaultServerPath)
37+
38+
return &Config{
39+
AnthropicAPIKey: apiKey,
40+
MCPServerPath: serverPath,
41+
}, nil
42+
}

mcp-client/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
)
1010

1111
require (
12+
github.com/go-chi/chi/v5 v5.2.3 // indirect
1213
github.com/google/jsonschema-go v0.3.0 // indirect
1314
github.com/tidwall/gjson v1.18.0 // indirect
1415
github.com/tidwall/match v1.1.1 // indirect

mcp-client/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/anthropics/anthropic-sdk-go v1.18.0 h1:jfxRA7AqZoCm83nHO/OVQp8xuwjUKt
22
github.com/anthropics/anthropic-sdk-go v1.18.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
33
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
44
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
6+
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
57
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
68
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
79
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=

mcp-client/main

-12.8 MB
Binary file not shown.

0 commit comments

Comments
 (0)