@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.
- package/README.md +200 -47
- package/cmd/llm-wiki/hook_test.go +108 -0
- package/cmd/llm-wiki/main.go +457 -11
- package/go.mod +1 -1
- package/internal/mcpautostart/autostart.go +285 -0
- package/internal/mcpautostart/autostart_test.go +169 -0
- package/internal/mcpautostart/detach_unix.go +9 -0
- package/internal/mcpautostart/detach_windows.go +7 -0
- package/internal/mcpautostart/proxy.go +162 -0
- package/internal/sessionctx/sessionctx.go +649 -0
- package/internal/sessionctx/sessionctx_test.go +415 -0
- package/internal/wiki/cleanup.go +1189 -0
- package/internal/wiki/cleanup_plan.go +569 -0
- package/internal/wiki/cleanup_test.go +450 -0
- package/internal/wiki/init.go +77 -0
- package/internal/wiki/vault_test.go +41 -0
- package/npm/lib/runner.js +50 -10
- package/package.json +1 -1
|
@@ -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,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
|
+
}
|