Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
160 lines (141 loc) · 6.01 KB
/
Copy pathmain.go
File metadata and controls
160 lines (141 loc) · 6.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
package main
import (
"fmt"
"log"
"os"
"slices"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var logger = log.New(os.Stderr, "[turboweb] ", 0)
// serverVersion is advertised to MCP clients and to /version on the
// daemon. Bumping it on a release lets running MCP instances detect a
// stale daemon and respawn it instead of routing through the old build.
// The single source of truth is the repo-root VERSION file, injected at
// build time via -ldflags "-X main.serverVersion=$(VERSION)". The "dev"
// fallback keeps `go run .`, tests, and IDE debuggers working without make.
var serverVersion = "dev"
func main() {
// --ws-server: run as a standalone WebSocket daemon (no MCP stdio).
if len(os.Args) > 1 && os.Args[1] == "--ws-server" {
logger.Println("Starting as WebSocket daemon")
initDaemonInfo()
if err := RunDaemon(); err != nil {
fmt.Fprintf(os.Stderr, "Daemon error: %v\n", err)
os.Exit(1)
}
return
}
// --self-update: download + install the latest release, then exit.
// Lets a SessionStart hook (or a manual run) upgrade the binary
// without going through the self_update MCP tool.
if len(os.Args) > 1 && os.Args[1] == "--self-update" {
res, err := performSelfUpdate()
if err != nil {
fmt.Fprintf(os.Stderr, "self-update failed: %v\n", err)
os.Exit(1)
}
fmt.Println(res.Message)
return
}
initSession()
initHaiku()
initGemini()
loadBrowserConfig()
if err := initDB(); err != nil {
logger.Printf("Warning: custom tools DB failed to init: %v", err)
}
go startWebSocket()
hooks := &server.Hooks{}
hooks.AddAfterInitialize(initializeHook())
s := server.NewMCPServer(
"turboweb",
serverVersion,
server.WithToolCapabilities(false),
server.WithHooks(hooks),
// Surface a top-level instruction the host can fold into the
// system prompt. Every tool call should be preceded by an
// `intent` argument — a one-line natural-language narration of
// what the agent is about to do. The browser popup and on-page
// overlay rely on this so the human user can follow along
// without having to expand each tool-call dump.
server.WithInstructions(strings.TrimSpace(`
TurboWeb MCP drives a real browser tab on the user's screen — a human is
watching. ⚠️ Every tool call MUST include `+"`intent`"+`: one short sentence
narrating what you're about to do (shown live as a toast on the page and in
the extension popup). Without it the overlay goes silent.
⚠️ Prefer user-visible tools — `+"`click` / `type_text` / `scroll`"+` (or
`+"`cdp_*`"+` when you need trusted events) — over `+"`execute_js`"+`, which
bypasses the overlay entirely. For reads, prefer `+"`extract_text` / `find_text`"+`
/ `+"`inspect` / `get_interactive_map`"+` — they animate; raw JS reads don't.
⚠️ Call `+"`introduce`"+` FIRST, once per session, with your name (and model /
host / task if known). Every other tool refuses to run until you do, so the
human sees who is driving instead of a guessed editor name.
For deeper guidance, invoke the `+"`agent-rules`"+` prompt.
`)),
)
registerAllTools(s)
registerPrompts(s)
logger.Printf("turboweb MCP server running (stdio) — session: %s", getSessionLabel())
if err := server.ServeStdio(s); err != nil {
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
os.Exit(1)
}
}
func registerAllTools(s *server.MCPServer) {
registerSessionTools(s)
registerBrowserTools(s)
registerDomTools(s)
registerFrameTools(s)
registerFormTools(s)
registerInteractionTools(s)
registerJsTools(s)
registerDebugTools(s)
registerFileTools(s)
registerUpdateTools(s)
registerCustomTools(s)
}
// textResult converts any value to a JSON text tool result.
func textResult(data any) (*mcp.CallToolResult, error) {
return mcp.NewToolResultText(toJSON(data)), nil
}
// intentParamDescription is shown to the agent. We keep it a single sentence
// so it fits in the model's tool-list view without dominating each tool's own
// description. The instruction is uniform across every tool.
const intentParamDescription = "Required: a one-sentence narration of what you're about to do (and ideally why), written for the human watching. Shown live in the browser popup and on the page itself, so the user can follow along without reading every tool call. Example: \"Clicking Submit because the form is filled.\""
// addTool wraps server.AddTool, injecting a uniform `intent` parameter into
// every tool's input schema. The agent fills it in to narrate each call; the
// MCP server pulls it out (see rawArgs) and forwards it as `_intent` to the
// extension and on-page overlay rather than to the tool handler itself.
//
// `intent` is marked Required so MCP hosts that validate input (Claude Code,
// Cursor, etc.) reject calls without one — forcing the agent to write a
// one-line narration before the tool executes. This is what makes the
// "calling turboweb 2 times" transcript line in Claude Code expand to
// readable per-call descriptions.
func addTool(s *server.MCPServer, tool mcp.Tool, handler server.ToolHandlerFunc) {
if tool.InputSchema.Properties == nil {
tool.InputSchema.Properties = map[string]any{}
}
if _, exists := tool.InputSchema.Properties["intent"]; !exists {
tool.InputSchema.Properties["intent"] = map[string]any{
"type": "string",
"description": intentParamDescription,
}
}
if !slices.Contains(tool.InputSchema.Required, "intent") {
tool.InputSchema.Required = append(tool.InputSchema.Required, "intent")
}
// Tools that declare a `frame` param honor the sticky default frame
// (frame_set); non-frame tools are untouched. One chokepoint covers every
// frame-aware tool — DOM and cdp_* alike — with no per-tool wiring.
// `navigate` is excluded: there `frame` means "navigate THIS frame", a
// deliberate per-call target, so silently inheriting a sticky frame would
// turn a full-page navigation into a frame-only one.
if _, hasFrame := tool.InputSchema.Properties["frame"]; hasFrame && tool.Name != "navigate" {
handler = injectStickyFrame(handler)
}
handler = requireIntroduction(tool.Name, handler)
s.AddTool(tool, handler)
}