@m16khb/llm-wiki 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ package mcpautostart
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "net/http"
11
+ "os"
12
+ "os/exec"
13
+ "path/filepath"
14
+ "strings"
15
+ "time"
16
+
17
+ "github.com/modelcontextprotocol/go-sdk/mcp"
18
+
19
+ "github.com/m16khb/llm-wiki/internal/mcpserver"
20
+ "github.com/m16khb/llm-wiki/internal/store"
21
+ "github.com/m16khb/llm-wiki/internal/wiki"
22
+ )
23
+
24
+ const (
25
+ defaultAddr = "127.0.0.1:39233"
26
+ defaultStartupTimeout = 15 * time.Second
27
+ defaultToolTimeout = 60 * time.Second
28
+ )
29
+
30
+ // Config configures the stdio shim that ensures and proxies to the HTTP daemon.
31
+ type Config struct {
32
+ VaultRoot string
33
+ DBPath string
34
+ Addr string
35
+ StartupTimeout time.Duration
36
+ ToolTimeout time.Duration
37
+ InitVault bool
38
+ }
39
+
40
+ type healthState struct {
41
+ OK bool `json:"ok"`
42
+ Vault string `json:"vault"`
43
+ DB string `json:"db"`
44
+ }
45
+
46
+ type healthProbe func(context.Context, Config) (healthState, error)
47
+ type daemonStarter func(context.Context, Config) error
48
+ type sleepFunc func(context.Context) error
49
+
50
+ // Run starts the stdio MCP shim. The shim itself does not touch the vault; it
51
+ // starts or reuses the singleton HTTP daemon, then proxies all MCP tool calls to
52
+ // that daemon.
53
+ func Run(ctx context.Context, cfg Config) error {
54
+ cfg = normalizeConfig(cfg)
55
+ if err := EnsureDaemon(ctx, cfg); err != nil {
56
+ return err
57
+ }
58
+ server := mcpserver.NewWithBackend(NewProxyBackend(EndpointURL(cfg.Addr), WithToolTimeout(cfg.ToolTimeout)))
59
+ return server.Run(ctx, &mcp.StdioTransport{})
60
+ }
61
+
62
+ // EnsureDaemon verifies the daemon health endpoint and starts the daemon in the
63
+ // background when it is not already reachable.
64
+ func EnsureDaemon(ctx context.Context, cfg Config) error {
65
+ return ensureDaemonWithHooks(ctx, normalizeConfig(cfg), probeHealth, startDaemonProcess, defaultSleep)
66
+ }
67
+
68
+ func ensureDaemonWithHooks(ctx context.Context, cfg Config, probe healthProbe, start daemonStarter, sleep sleepFunc) error {
69
+ cfg = normalizeConfig(cfg)
70
+ if cfg.InitVault {
71
+ if _, err := wiki.InitVault(cfg.VaultRoot); err != nil {
72
+ return err
73
+ }
74
+ }
75
+ return ensureDaemon(ctx, cfg, probe, start, sleep)
76
+ }
77
+
78
+ func ensureDaemon(ctx context.Context, cfg Config, probe healthProbe, start daemonStarter, sleep sleepFunc) error {
79
+ cfg = normalizeConfig(cfg)
80
+ state, err := probe(ctx, cfg)
81
+ if err == nil {
82
+ return validateHealth(cfg, state)
83
+ }
84
+ if err := start(ctx, cfg); err != nil {
85
+ return err
86
+ }
87
+ deadline := time.Now().Add(cfg.StartupTimeout)
88
+ var lastErr error = err
89
+ for {
90
+ state, err = probe(ctx, cfg)
91
+ if err == nil {
92
+ return validateHealth(cfg, state)
93
+ }
94
+ lastErr = err
95
+ if time.Now().After(deadline) {
96
+ return fmt.Errorf("llm-wiki daemon did not become healthy at %s within %s: %w", HealthURL(cfg.Addr), cfg.StartupTimeout, lastErr)
97
+ }
98
+ if err := sleep(ctx); err != nil {
99
+ return err
100
+ }
101
+ }
102
+ }
103
+
104
+ func normalizeConfig(cfg Config) Config {
105
+ if strings.TrimSpace(cfg.Addr) == "" {
106
+ cfg.Addr = defaultAddr
107
+ }
108
+ if cfg.StartupTimeout <= 0 {
109
+ cfg.StartupTimeout = defaultStartupTimeout
110
+ }
111
+ if cfg.ToolTimeout <= 0 {
112
+ cfg.ToolTimeout = defaultToolTimeout
113
+ }
114
+ return cfg
115
+ }
116
+
117
+ func EndpointURL(addr string) string {
118
+ if strings.TrimSpace(addr) == "" {
119
+ addr = defaultAddr
120
+ }
121
+ return "http://" + strings.TrimRight(addr, "/") + "/mcp"
122
+ }
123
+
124
+ func HealthURL(addr string) string {
125
+ if strings.TrimSpace(addr) == "" {
126
+ addr = defaultAddr
127
+ }
128
+ return "http://" + strings.TrimRight(addr, "/") + "/healthz"
129
+ }
130
+
131
+ func probeHealth(ctx context.Context, cfg Config) (healthState, error) {
132
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, HealthURL(cfg.Addr), nil)
133
+ if err != nil {
134
+ return healthState{}, err
135
+ }
136
+ client := &http.Client{Timeout: 2 * time.Second}
137
+ res, err := client.Do(req)
138
+ if err != nil {
139
+ return healthState{}, err
140
+ }
141
+ defer res.Body.Close()
142
+ if res.StatusCode != http.StatusOK {
143
+ return healthState{}, fmt.Errorf("health returned %s", res.Status)
144
+ }
145
+ var state healthState
146
+ if err := json.NewDecoder(res.Body).Decode(&state); err != nil {
147
+ return healthState{}, err
148
+ }
149
+ if !state.OK {
150
+ return healthState{}, errors.New("health returned ok=false")
151
+ }
152
+ return state, nil
153
+ }
154
+
155
+ func validateHealth(cfg Config, state healthState) error {
156
+ if !state.OK {
157
+ return errors.New("llm-wiki daemon health returned ok=false")
158
+ }
159
+ if cfg.VaultRoot != "" {
160
+ expected, err := canonicalVault(cfg.VaultRoot)
161
+ if err != nil {
162
+ return err
163
+ }
164
+ actual := canonicalExistingPath(state.Vault)
165
+ if actual != expected {
166
+ return fmt.Errorf("llm-wiki daemon at %s is serving a different vault: got %s, want %s", EndpointURL(cfg.Addr), actual, expected)
167
+ }
168
+ }
169
+ expectedDB, err := canonicalDBPath(cfg.DBPath)
170
+ if err != nil {
171
+ return err
172
+ }
173
+ actualDB := canonicalExistingPath(state.DB)
174
+ if actualDB != "" && actualDB != expectedDB {
175
+ return fmt.Errorf("llm-wiki daemon at %s is using a different db: got %s, want %s", EndpointURL(cfg.Addr), actualDB, expectedDB)
176
+ }
177
+ return nil
178
+ }
179
+
180
+ func canonicalVault(root string) (string, error) {
181
+ v, err := wiki.New(root)
182
+ if err != nil {
183
+ return "", err
184
+ }
185
+ return canonicalExistingPath(v.Root), nil
186
+ }
187
+
188
+ func canonicalDBPath(path string) (string, error) {
189
+ if path == "" {
190
+ path = store.DefaultPath()
191
+ }
192
+ return filepath.Abs(expandHome(path))
193
+ }
194
+
195
+ func canonicalExistingPath(path string) string {
196
+ if path == "" {
197
+ return ""
198
+ }
199
+ abs, err := filepath.Abs(expandHome(path))
200
+ if err != nil {
201
+ return path
202
+ }
203
+ return abs
204
+ }
205
+
206
+ func startDaemonProcess(ctx context.Context, cfg Config) error {
207
+ if err := ctx.Err(); err != nil {
208
+ return err
209
+ }
210
+ exe, err := os.Executable()
211
+ if err != nil {
212
+ return fmt.Errorf("find llm-wiki executable: %w", err)
213
+ }
214
+ args := []string{"serve", "--addr", cfg.Addr}
215
+ if strings.TrimSpace(cfg.VaultRoot) != "" {
216
+ args = append(args, "--vault", cfg.VaultRoot)
217
+ }
218
+ if strings.TrimSpace(cfg.DBPath) != "" {
219
+ args = append(args, "--db", cfg.DBPath)
220
+ }
221
+ cmd := exec.Command(exe, args...)
222
+ cmd.Stdin = nil
223
+ cmd.SysProcAttr = detachedSysProcAttr()
224
+ logFile, err := openDaemonLog(cfg)
225
+ if err != nil {
226
+ return err
227
+ }
228
+ cmd.Stdout = logFile
229
+ cmd.Stderr = logFile
230
+ if err := cmd.Start(); err != nil {
231
+ _ = logFile.Close()
232
+ return fmt.Errorf("start llm-wiki daemon: %w", err)
233
+ }
234
+ go func() {
235
+ _ = cmd.Wait()
236
+ _ = logFile.Close()
237
+ }()
238
+ return nil
239
+ }
240
+
241
+ func openDaemonLog(cfg Config) (*os.File, error) {
242
+ if path := strings.TrimSpace(os.Getenv("LLM_WIKI_DAEMON_LOG")); path != "" {
243
+ path = expandHome(path)
244
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
245
+ return nil, err
246
+ }
247
+ return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
248
+ }
249
+ dir, err := os.UserCacheDir()
250
+ if err != nil || dir == "" {
251
+ dir = os.TempDir()
252
+ }
253
+ dir = filepath.Join(dir, "llm-wiki")
254
+ if err := os.MkdirAll(dir, 0o755); err != nil {
255
+ return nil, err
256
+ }
257
+ h := sha256.Sum256([]byte(cfg.Addr + "\x00" + cfg.VaultRoot + "\x00" + cfg.DBPath))
258
+ name := "daemon-autostart-" + hex.EncodeToString(h[:4]) + ".log"
259
+ return os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
260
+ }
261
+
262
+ func defaultSleep(ctx context.Context) error {
263
+ timer := time.NewTimer(100 * time.Millisecond)
264
+ defer timer.Stop()
265
+ select {
266
+ case <-ctx.Done():
267
+ return ctx.Err()
268
+ case <-timer.C:
269
+ return nil
270
+ }
271
+ }
272
+
273
+ func expandHome(path string) string {
274
+ if path == "~" {
275
+ if home, err := os.UserHomeDir(); err == nil {
276
+ return home
277
+ }
278
+ }
279
+ if strings.HasPrefix(path, "~/") {
280
+ if home, err := os.UserHomeDir(); err == nil {
281
+ return filepath.Join(home, path[2:])
282
+ }
283
+ }
284
+ return path
285
+ }
@@ -0,0 +1,169 @@
1
+ package mcpautostart
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/m16khb/llm-wiki/internal/daemon"
13
+ "github.com/m16khb/llm-wiki/internal/service"
14
+ "github.com/m16khb/llm-wiki/internal/wiki"
15
+ )
16
+
17
+ func TestEnsureDaemonStartsWhenHealthUnavailable(t *testing.T) {
18
+ ctx := context.Background()
19
+ cfg := Config{Addr: "127.0.0.1:39233", StartupTimeout: testTimeout}
20
+ started := false
21
+ probeCalls := 0
22
+
23
+ err := ensureDaemon(ctx, cfg,
24
+ func(context.Context, Config) (healthState, error) {
25
+ probeCalls++
26
+ if started {
27
+ return healthState{OK: true}, nil
28
+ }
29
+ return healthState{}, errors.New("connection refused")
30
+ },
31
+ func(context.Context, Config) error {
32
+ started = true
33
+ return nil
34
+ },
35
+ testSleep,
36
+ )
37
+ if err != nil {
38
+ t.Fatalf("ensureDaemon() error = %v", err)
39
+ }
40
+ if !started {
41
+ t.Fatalf("expected daemon starter to be called")
42
+ }
43
+ if probeCalls < 2 {
44
+ t.Fatalf("expected health to be probed before and after start, got %d", probeCalls)
45
+ }
46
+ }
47
+
48
+ func TestEnsureDaemonInitializesMissingVaultWhenEnabled(t *testing.T) {
49
+ ctx := context.Background()
50
+ root := filepath.Join(t.TempDir(), "missing-vault")
51
+ cfg := Config{VaultRoot: root, Addr: "127.0.0.1:39233", StartupTimeout: testTimeout, InitVault: true}
52
+ started := false
53
+
54
+ err := ensureDaemonWithHooks(ctx, cfg,
55
+ func(context.Context, Config) (healthState, error) {
56
+ if started {
57
+ return healthState{OK: true, Vault: root}, nil
58
+ }
59
+ return healthState{}, errors.New("connection refused")
60
+ },
61
+ func(context.Context, Config) error {
62
+ started = true
63
+ return nil
64
+ },
65
+ testSleep,
66
+ )
67
+ if err != nil {
68
+ t.Fatalf("ensureDaemonWithHooks() error = %v", err)
69
+ }
70
+ for _, rel := range wiki.StandardVaultDirs() {
71
+ if st, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil || !st.IsDir() {
72
+ t.Fatalf("expected initialized dir %s, stat=%v err=%v", rel, st, err)
73
+ }
74
+ }
75
+ }
76
+
77
+ func TestEnsureDaemonRejectsHealthyDifferentVault(t *testing.T) {
78
+ ctx := context.Background()
79
+ cfg := Config{VaultRoot: t.TempDir(), Addr: "127.0.0.1:39233", StartupTimeout: testTimeout}
80
+ started := false
81
+
82
+ err := ensureDaemon(ctx, cfg,
83
+ func(context.Context, Config) (healthState, error) {
84
+ return healthState{OK: true, Vault: filepath.Join(t.TempDir(), "other")}, nil
85
+ },
86
+ func(context.Context, Config) error {
87
+ started = true
88
+ return nil
89
+ },
90
+ testSleep,
91
+ )
92
+ if err == nil || !strings.Contains(err.Error(), "different vault") {
93
+ t.Fatalf("expected different vault error, got %v", err)
94
+ }
95
+ if started {
96
+ t.Fatalf("starter should not run when another daemon owns the address")
97
+ }
98
+ }
99
+
100
+ func TestProxyBackendForwardsToolsToHTTPDaemon(t *testing.T) {
101
+ ctx := context.Background()
102
+ root := t.TempDir()
103
+ writeTestPage(t, root, "20-wiki/concepts/existing.md", `---
104
+ title: Existing
105
+ type: concept
106
+ status: active
107
+ created: 2026-05-23
108
+ updated: 2026-05-23
109
+ tags: [proxy]
110
+ domain: test
111
+ ---
112
+
113
+ # Existing
114
+
115
+ Proxy search target.
116
+ `)
117
+
118
+ svc, err := service.New(service.Config{VaultRoot: root, DBPath: filepath.Join(root, "queue.db")})
119
+ if err != nil {
120
+ t.Fatalf("service.New() error = %v", err)
121
+ }
122
+ defer svc.Close()
123
+
124
+ httpServer := httptest.NewServer(daemon.NewHandler(svc))
125
+ defer httpServer.Close()
126
+
127
+ backend := NewProxyBackend(httpServer.URL + "/mcp")
128
+ info, err := backend.Info()
129
+ if err != nil {
130
+ t.Fatalf("proxy Info() error = %v", err)
131
+ }
132
+ if info.Root != root || info.MarkdownFiles != 1 {
133
+ t.Fatalf("unexpected proxied info: %+v", info)
134
+ }
135
+
136
+ results, err := backend.Search(wiki.SearchOptions{Query: "target", Limit: 5})
137
+ if err != nil {
138
+ t.Fatalf("proxy Search() error = %v", err)
139
+ }
140
+ if len(results) != 1 || results[0].Path != "20-wiki/concepts/existing.md" {
141
+ t.Fatalf("unexpected proxied search results: %+v", results)
142
+ }
143
+
144
+ capture, err := backend.CaptureContext(ctx, wiki.CaptureInput{Title: "Via Proxy", Body: "body", Slug: "via-proxy"})
145
+ if err != nil {
146
+ t.Fatalf("proxy CaptureContext() error = %v", err)
147
+ }
148
+ if capture.Path != "20-wiki/concepts/via-proxy.md" || !capture.Created {
149
+ t.Fatalf("unexpected capture result: %+v", capture)
150
+ }
151
+ if _, _, err := svc.ReadPage("via-proxy", 0); err != nil {
152
+ t.Fatalf("daemon service did not receive proxied capture: %v", err)
153
+ }
154
+ }
155
+
156
+ const testTimeout = 1000000
157
+
158
+ func testSleep(context.Context) error { return nil }
159
+
160
+ func writeTestPage(t *testing.T, root, rel, content string) {
161
+ t.Helper()
162
+ abs := filepath.Join(root, filepath.FromSlash(rel))
163
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
164
+ t.Fatalf("mkdir %s: %v", rel, err)
165
+ }
166
+ if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
167
+ t.Fatalf("write %s: %v", rel, err)
168
+ }
169
+ }
@@ -0,0 +1,9 @@
1
+ //go:build !windows
2
+
3
+ package mcpautostart
4
+
5
+ import "syscall"
6
+
7
+ func detachedSysProcAttr() *syscall.SysProcAttr {
8
+ return &syscall.SysProcAttr{Setpgid: true}
9
+ }
@@ -0,0 +1,7 @@
1
+ //go:build windows
2
+
3
+ package mcpautostart
4
+
5
+ import "syscall"
6
+
7
+ func detachedSysProcAttr() *syscall.SysProcAttr { return nil }
@@ -0,0 +1,162 @@
1
+ package mcpautostart
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/modelcontextprotocol/go-sdk/mcp"
12
+
13
+ "github.com/m16khb/llm-wiki/internal/mcpserver"
14
+ "github.com/m16khb/llm-wiki/internal/wiki"
15
+ )
16
+
17
+ // ProxyBackend implements mcpserver.Backend by forwarding each tool call to the
18
+ // singleton streamable HTTP daemon.
19
+ type ProxyBackend struct {
20
+ Endpoint string
21
+ ToolTimeout time.Duration
22
+ }
23
+
24
+ type ProxyOption func(*ProxyBackend)
25
+
26
+ func WithToolTimeout(timeout time.Duration) ProxyOption {
27
+ return func(b *ProxyBackend) {
28
+ b.ToolTimeout = timeout
29
+ }
30
+ }
31
+
32
+ func NewProxyBackend(endpoint string, opts ...ProxyOption) *ProxyBackend {
33
+ b := &ProxyBackend{Endpoint: endpoint, ToolTimeout: defaultToolTimeout}
34
+ for _, opt := range opts {
35
+ opt(b)
36
+ }
37
+ return b
38
+ }
39
+
40
+ func (b *ProxyBackend) Info() (wiki.Info, error) {
41
+ var out struct {
42
+ Info wiki.Info `json:"info"`
43
+ }
44
+ if err := b.callTool(context.Background(), "wiki_info", map[string]any{}, &out); err != nil {
45
+ return wiki.Info{}, err
46
+ }
47
+ return out.Info, nil
48
+ }
49
+
50
+ func (b *ProxyBackend) Search(opts wiki.SearchOptions) ([]wiki.SearchResult, error) {
51
+ var out struct {
52
+ Query string `json:"query"`
53
+ Results []wiki.SearchResult `json:"results"`
54
+ }
55
+ if err := b.callTool(context.Background(), "wiki_search", map[string]any{
56
+ "query": opts.Query,
57
+ "limit": opts.Limit,
58
+ "roots": opts.Roots,
59
+ "include_archive": opts.IncludeArchive,
60
+ }, &out); err != nil {
61
+ return nil, err
62
+ }
63
+ return out.Results, nil
64
+ }
65
+
66
+ func (b *ProxyBackend) ReadPage(page string, maxBytes int) (wiki.Page, string, error) {
67
+ var out struct {
68
+ Page wiki.Page `json:"page"`
69
+ Content string `json:"content"`
70
+ }
71
+ if err := b.callTool(context.Background(), "wiki_read", map[string]any{"page": page, "max_bytes": maxBytes}, &out); err != nil {
72
+ return wiki.Page{}, "", err
73
+ }
74
+ return out.Page, out.Content, nil
75
+ }
76
+
77
+ func (b *ProxyBackend) Lint() (wiki.LintResult, error) {
78
+ var out struct {
79
+ Result wiki.LintResult `json:"result"`
80
+ }
81
+ if err := b.callTool(context.Background(), "wiki_lint", map[string]any{}, &out); err != nil {
82
+ return wiki.LintResult{}, err
83
+ }
84
+ return out.Result, nil
85
+ }
86
+
87
+ func (b *ProxyBackend) CaptureContext(ctx context.Context, in wiki.CaptureInput) (wiki.CaptureResult, error) {
88
+ var out struct {
89
+ Result wiki.CaptureResult `json:"result"`
90
+ }
91
+ if err := b.callTool(ctx, "wiki_capture", in, &out); err != nil {
92
+ return wiki.CaptureResult{}, err
93
+ }
94
+ return out.Result, nil
95
+ }
96
+
97
+ func (b *ProxyBackend) callTool(ctx context.Context, name string, args any, out any) error {
98
+ if strings.TrimSpace(b.Endpoint) == "" {
99
+ return errors.New("proxy endpoint is required")
100
+ }
101
+ if ctx == nil {
102
+ ctx = context.Background()
103
+ }
104
+ if b.ToolTimeout > 0 {
105
+ var cancel context.CancelFunc
106
+ ctx, cancel = context.WithTimeout(ctx, b.ToolTimeout)
107
+ defer cancel()
108
+ }
109
+ client := mcp.NewClient(&mcp.Implementation{Name: "llm-wiki-autostart", Version: mcpserver.Version}, nil)
110
+ session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: b.Endpoint, DisableStandaloneSSE: true}, nil)
111
+ if err != nil {
112
+ return fmt.Errorf("connect to llm-wiki daemon %s: %w", b.Endpoint, err)
113
+ }
114
+ defer session.Close()
115
+ result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
116
+ if err != nil {
117
+ return fmt.Errorf("call daemon tool %s: %w", name, err)
118
+ }
119
+ if result.IsError {
120
+ return fmt.Errorf("daemon tool %s failed: %s", name, toolResultText(result))
121
+ }
122
+ return decodeStructuredContent(result, out)
123
+ }
124
+
125
+ func decodeStructuredContent(result *mcp.CallToolResult, out any) error {
126
+ if out == nil {
127
+ return nil
128
+ }
129
+ if result == nil || result.StructuredContent == nil {
130
+ return errors.New("daemon response missing structuredContent")
131
+ }
132
+ var data []byte
133
+ switch v := result.StructuredContent.(type) {
134
+ case json.RawMessage:
135
+ data = v
136
+ case []byte:
137
+ data = v
138
+ default:
139
+ var err error
140
+ data, err = json.Marshal(v)
141
+ if err != nil {
142
+ return fmt.Errorf("marshal structuredContent: %w", err)
143
+ }
144
+ }
145
+ if err := json.Unmarshal(data, out); err != nil {
146
+ return fmt.Errorf("decode structuredContent: %w", err)
147
+ }
148
+ return nil
149
+ }
150
+
151
+ func toolResultText(result *mcp.CallToolResult) string {
152
+ if result == nil {
153
+ return ""
154
+ }
155
+ parts := make([]string, 0, len(result.Content))
156
+ for _, content := range result.Content {
157
+ if text, ok := content.(*mcp.TextContent); ok {
158
+ parts = append(parts, text.Text)
159
+ }
160
+ }
161
+ return strings.Join(parts, "\n")
162
+ }