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.
- package/lib.mjs +159 -0
- package/package.json +33 -0
- package/server.mjs +170 -0
- 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
|
+
}
|