foxcode-channel 0.1.0

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.
Files changed (4) hide show
  1. package/lib.mjs +159 -0
  2. package/package.json +33 -0
  3. package/server.mjs +170 -0
  4. package/validator.mjs +12 -0
package/lib.mjs ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * FoxCode — Channel plugin shared logic.
3
+ * Pure functions and protocol definitions, testable without MCP/WebSocket.
4
+ */
5
+
6
+ /**
7
+ * Sequence counter for message IDs.
8
+ * Exposed as object so tests can reset it.
9
+ */
10
+ export const state = { seq: 0 }
11
+
12
+ /** Generate a unique message ID. */
13
+ export function nextId() {
14
+ return `m${Date.now()}-${++state.seq}`
15
+ }
16
+
17
+ /**
18
+ * Build channel notification metadata from an extension message.
19
+ * @param {object} msg - Extension message with id, tab?, text
20
+ * @returns {{content: string, meta: object}}
21
+ */
22
+ export function buildChannelMeta(msg) {
23
+ const id = msg.id || nextId()
24
+ const meta = { chat_id: 'web', message_id: id, user: 'web', ts: new Date().toISOString() }
25
+ if (msg.tab?.url) meta.tab_url = msg.tab.url
26
+ if (msg.tab?.title) meta.tab_title = msg.tab.title
27
+ return { content: msg.text, meta }
28
+ }
29
+
30
+ /**
31
+ * Build a reply broadcast message for the extension.
32
+ * @param {string} text - Reply text
33
+ * @param {string} [replyTo] - Message ID to reply to
34
+ * @returns {object}
35
+ */
36
+ export function buildReplyMessage(text, replyTo) {
37
+ const id = nextId()
38
+ return { type: 'msg', id, from: 'assistant', text, ts: Date.now(), replyTo }
39
+ }
40
+
41
+ /**
42
+ * Build an edit broadcast message.
43
+ * @param {string} messageId
44
+ * @param {string} text
45
+ * @returns {object}
46
+ */
47
+ export function buildEditMessage(messageId, text) {
48
+ return { type: 'edit', id: messageId, text }
49
+ }
50
+
51
+ /**
52
+ * Build a tool_use broadcast message.
53
+ * @param {string} tool
54
+ * @param {object} params
55
+ * @returns {object}
56
+ */
57
+ export function buildToolUseMessage(tool, params) {
58
+ const id = nextId()
59
+ return { type: 'tool_use', id, tool, params, ts: Date.now() }
60
+ }
61
+
62
+ /**
63
+ * Build a tool_result broadcast message.
64
+ * @param {string} tool
65
+ * @param {string} content
66
+ * @returns {object}
67
+ */
68
+ export function buildToolResultMessage(tool, content) {
69
+ const id = nextId()
70
+ return { type: 'tool_result', id, tool, content, ts: Date.now() }
71
+ }
72
+
73
+ /**
74
+ * MCP tool definitions exposed by the channel plugin.
75
+ */
76
+ export const TOOL_DEFINITIONS = [
77
+ {
78
+ name: 'reply',
79
+ description: 'Send a message to the Firefox browser sidebar.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ text: { type: 'string', description: 'Message text' },
84
+ reply_to: { type: 'string', description: 'Message ID to reply to (optional)' },
85
+ },
86
+ required: ['text'],
87
+ },
88
+ },
89
+ {
90
+ name: 'edit_message',
91
+ description: 'Edit a previously sent message in the browser sidebar.',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ message_id: { type: 'string', description: 'ID of the message to edit' },
96
+ text: { type: 'string', description: 'New message text' },
97
+ },
98
+ required: ['message_id', 'text'],
99
+ },
100
+ },
101
+ {
102
+ name: 'evalInBrowser',
103
+ description: [
104
+ 'Execute JavaScript in Firefox browser. Code runs in extension background with async browser API.',
105
+ 'Multi-page workflows supported (survives navigation).',
106
+ 'navigate() automatically opens a new tab for agent work (preserves user\'s active tab).',
107
+ 'Subsequent operations target this managed tab. closeTab() without args closes it;',
108
+ 'next navigate() creates a fresh tab.',
109
+ '',
110
+ 'Usage: write JS code using `api` object. Destructure or access directly.',
111
+ 'All selector-based helpers auto-wait for element (poll 100ms, default timeout 2000ms).',
112
+ 'Override: pass {timeout: 5000} as last arg.',
113
+ '',
114
+ 'API Reference:',
115
+ '- Wait: api.waitFor(sel, {timeout?,visible?})',
116
+ '- DOM: api.click(sel,opts?), api.dblclick(sel,opts?), api.type(sel,text,opts?),',
117
+ ' api.fill(sel,val,opts?), api.select(sel,val,opts?), api.check(sel,opts?),',
118
+ ' api.uncheck(sel,opts?), api.hover(sel,opts?), api.press(key),',
119
+ ' api.scrollTo(x,y), api.scrollBy(dx,dy)',
120
+ '- Query: api.$(sel,opts?), api.$$(sel,opts?), api.snapshot(sel?,opts?),',
121
+ ' api.getTitle(), api.getUrl(), api.getSelectedText()',
122
+ '- Eval: api.eval(expr) — execute in page JS context (access page vars, React state)',
123
+ '- Navigation: api.navigate(url), api.goBack(), api.goForward(), api.reload()',
124
+ ' — all await page load',
125
+ '- Tabs: api.getTabs(), api.newTab(url?), api.closeTab(idx?), api.selectTab(idx)',
126
+ ' closeTab() without args closes managed tab; with idx closes by index',
127
+ '- Storage: api.localStorage.{list,get,set,delete,clear}(),',
128
+ ' api.sessionStorage.{…}()',
129
+ '- Cookies: api.getCookies(filter?), api.setCookie(details),',
130
+ ' api.deleteCookie(name,url)',
131
+ '- Window: api.resize(w,h), api.screenshot() → base64',
132
+ '- Dialog: api.interceptDialog("accept"|"dismiss")',
133
+ '- Console: api.captureConsole(), api.getConsoleLogs()',
134
+ '',
135
+ 'opts = {timeout: ms} — override auto-wait timeout per call',
136
+ '',
137
+ 'Example:',
138
+ 'const {navigate, fill, click, snapshot} = api;',
139
+ 'await navigate("https://example.com/login");',
140
+ 'await fill("#email", "user@test.com");',
141
+ 'await click("button[type=submit]");',
142
+ 'return await snapshot();',
143
+ ].join('\n'),
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ code: {
148
+ type: 'string',
149
+ description: 'JS code. Use `api.*` helpers. Async/await supported. Return value = tool result.',
150
+ },
151
+ timeout: {
152
+ type: 'number',
153
+ description: 'Timeout ms, default 30000',
154
+ },
155
+ },
156
+ required: ['code'],
157
+ },
158
+ },
159
+ ]
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "foxcode-channel",
3
+ "version": "0.1.0",
4
+ "description": "MCP channel plugin bridging Claude Code and FoxCode Firefox extension via WebSocket",
5
+ "type": "module",
6
+ "main": "server.mjs",
7
+ "bin": {
8
+ "foxcode-channel": "server.mjs"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.mjs",
12
+ "test": "node --test lib.test.mjs validator.test.mjs"
13
+ },
14
+ "keywords": ["claude-code", "mcp", "firefox", "browser-automation", "channel"],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/korchasa/foxcode.git",
18
+ "directory": "channel"
19
+ },
20
+ "license": "MIT",
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "files": [
25
+ "server.mjs",
26
+ "lib.mjs",
27
+ "validator.mjs"
28
+ ],
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.28.0",
31
+ "ws": "^8.20.0"
32
+ }
33
+ }
package/server.mjs ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FoxCode — Channel plugin for Firefox extension.
4
+ *
5
+ * MCP server that bridges Claude Code ↔ Firefox extension via WebSocket.
6
+ * - Declares claude/channel capability for bidirectional messaging
7
+ * - Exposes reply/edit_message tools for CC → browser responses
8
+ * - Exposes evalInBrowser tool for CC → browser automation (JS execution with ~30 API helpers)
9
+ * - WebSocket server on localhost for extension connection
10
+ */
11
+
12
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
+ import {
15
+ ListToolsRequestSchema,
16
+ CallToolRequestSchema,
17
+ } from '@modelcontextprotocol/sdk/types.js'
18
+ import { WebSocketServer } from 'ws'
19
+ import {
20
+ nextId, buildChannelMeta, buildReplyMessage, buildEditMessage,
21
+ buildToolUseMessage, buildToolResultMessage, TOOL_DEFINITIONS,
22
+ } from './lib.mjs'
23
+ import { validateCode } from './validator.mjs'
24
+
25
+ const PORT = Number(process.env.FOXCODE_PORT ?? 8787)
26
+
27
+ // --- WebSocket server for extension connection ---
28
+
29
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: PORT })
30
+ const clients = new Set()
31
+
32
+ /** Pending browser tool requests: request_id → {resolve, reject, timer} */
33
+ const pendingToolRequests = new Map()
34
+ const TOOL_TIMEOUT_MS = 30_000
35
+
36
+ function broadcast(msg) {
37
+ const data = JSON.stringify(msg)
38
+ for (const ws of clients) {
39
+ if (ws.readyState === 1) ws.send(data)
40
+ }
41
+ }
42
+
43
+ function hasClients() {
44
+ for (const ws of clients) {
45
+ if (ws.readyState === 1) return true
46
+ }
47
+ return false
48
+ }
49
+
50
+ /**
51
+ * Send a tool request to the browser extension and wait for the response.
52
+ * Returns a promise that resolves with the response content.
53
+ */
54
+ function requestFromBrowser(tool, params = {}) {
55
+ return new Promise((resolve, reject) => {
56
+ if (!hasClients()) {
57
+ reject(new Error('No browser extension connected'))
58
+ return
59
+ }
60
+ const requestId = `req-${nextId()}`
61
+ const timer = setTimeout(() => {
62
+ pendingToolRequests.delete(requestId)
63
+ reject(new Error(`Browser tool request timed out after ${TOOL_TIMEOUT_MS}ms`))
64
+ }, TOOL_TIMEOUT_MS)
65
+
66
+ pendingToolRequests.set(requestId, { resolve, reject, timer })
67
+ broadcast({ type: 'tool_request', request_id: requestId, tool, params })
68
+ })
69
+ }
70
+
71
+ wss.on('connection', (ws) => {
72
+ clients.add(ws)
73
+ ws.on('close', () => clients.delete(ws))
74
+ ws.on('error', () => clients.delete(ws))
75
+ ws.on('message', (raw) => {
76
+ try {
77
+ const msg = JSON.parse(String(raw))
78
+ handleExtensionMessage(msg)
79
+ } catch { /* ignore malformed messages */ }
80
+ })
81
+ })
82
+
83
+ function handleExtensionMessage(msg) {
84
+ process.stderr.write(`foxcode: rx ${msg.type} ${JSON.stringify(msg).slice(0, 200)}\n`)
85
+ switch (msg.type) {
86
+ case 'message': {
87
+ // FR-2: User message from browser → forward to CC via channel notification
88
+ const { content, meta } = buildChannelMeta(msg)
89
+ process.stderr.write(`foxcode: notify channel content=${content.slice(0, 100)}\n`)
90
+ mcp.notification({
91
+ method: 'notifications/claude/channel',
92
+ params: { content, meta },
93
+ })
94
+ break
95
+ }
96
+ case 'tool_response': {
97
+ // FR-5: Browser responding to a tool request from CC
98
+ const pending = pendingToolRequests.get(msg.request_id)
99
+ if (pending) {
100
+ clearTimeout(pending.timer)
101
+ pendingToolRequests.delete(msg.request_id)
102
+ pending.resolve(msg.content)
103
+ }
104
+ break
105
+ }
106
+ }
107
+ }
108
+
109
+ // --- MCP server ---
110
+
111
+ const mcp = new Server(
112
+ { name: 'foxcode', version: '0.1.0' },
113
+ {
114
+ capabilities: {
115
+ tools: {},
116
+ experimental: { 'claude/channel': {} },
117
+ },
118
+ instructions: [
119
+ 'Messages from the Firefox browser arrive as <channel source="foxcode" chat_id="web" message_id="..." tab_url="..." tab_title="...">.',
120
+ 'The tab_url and tab_title attributes show which page the user is currently viewing.',
121
+ 'The browser user reads the Firefox sidebar, not this terminal. Anything you want them to see MUST go through the reply tool — your transcript output never reaches the browser UI.',
122
+ 'Use evalInBrowser tool to execute JS in browser with full browser automation API (click, fill, navigate, snapshot, etc.).',
123
+ `Browser extension connects to ws://localhost:${PORT}.`,
124
+ ].join('\n'),
125
+ },
126
+ )
127
+
128
+ // --- MCP Tools ---
129
+
130
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
131
+ tools: TOOL_DEFINITIONS,
132
+ }))
133
+
134
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
135
+ const args = (req.params.arguments ?? {})
136
+ try {
137
+ switch (req.params.name) {
138
+ case 'reply': {
139
+ const replyMsg = buildReplyMessage(args.text, args.reply_to)
140
+ broadcast(replyMsg)
141
+ return { content: [{ type: 'text', text: `sent (${replyMsg.id})` }] }
142
+ }
143
+ case 'edit_message': {
144
+ broadcast(buildEditMessage(args.message_id, args.text))
145
+ return { content: [{ type: 'text', text: 'ok' }] }
146
+ }
147
+ case 'evalInBrowser': {
148
+ const { valid, error } = validateCode(args.code)
149
+ if (!valid) {
150
+ return { content: [{ type: 'text', text: `Syntax error: ${error}` }], isError: true }
151
+ }
152
+ const timeout = args.timeout ?? 30000
153
+ broadcast(buildToolUseMessage('evalInBrowser', { code: args.code }))
154
+ const result = await requestFromBrowser('EVAL_CODE', { code: args.code, timeout })
155
+ const text = typeof result === 'string' ? result : JSON.stringify(result)
156
+ broadcast(buildToolResultMessage('evalInBrowser', text))
157
+ return { content: [{ type: 'text', text }] }
158
+ }
159
+ default:
160
+ return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }
161
+ }
162
+ } catch (err) {
163
+ return { content: [{ type: 'text', text: `${req.params.name}: ${err.message}` }], isError: true }
164
+ }
165
+ })
166
+
167
+ // --- Start ---
168
+
169
+ await mcp.connect(new StdioServerTransport())
170
+ process.stderr.write(`foxcode: ws://localhost:${PORT}\n`)
package/validator.mjs ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Validate JS code syntax for evalInBrowser.
3
+ * Wraps in async function with `api` parameter to allow top-level await.
4
+ */
5
+ export function validateCode(code) {
6
+ try {
7
+ new Function('api', `return (async () => { ${code} })()`)
8
+ return { valid: true }
9
+ } catch (e) {
10
+ return { valid: false, error: e.message }
11
+ }
12
+ }