server.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. #!/usr/bin/env bun
  2. /**
  3. * Fake chat for Claude Code.
  4. *
  5. * Localhost web UI for testing the channel contract. No external service,
  6. * no tokens, no access control.
  7. */
  8. import { Server } from '@modelcontextprotocol/sdk/server/index.js'
  9. import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
  10. import {
  11. ListToolsRequestSchema,
  12. CallToolRequestSchema,
  13. } from '@modelcontextprotocol/sdk/types.js'
  14. import { readFileSync, writeFileSync, mkdirSync, statSync, copyFileSync } from 'fs'
  15. import { homedir } from 'os'
  16. import { join, extname, basename } from 'path'
  17. import type { ServerWebSocket } from 'bun'
  18. const PORT = Number(process.env.FAKECHAT_PORT ?? 8787)
  19. const STATE_DIR = join(homedir(), '.claude', 'channels', 'fakechat')
  20. const INBOX_DIR = join(STATE_DIR, 'inbox')
  21. const OUTBOX_DIR = join(STATE_DIR, 'outbox')
  22. type Msg = {
  23. id: string
  24. from: 'user' | 'assistant'
  25. text: string
  26. ts: number
  27. replyTo?: string
  28. file?: { url: string; name: string }
  29. }
  30. type Wire =
  31. | ({ type: 'msg' } & Msg)
  32. | { type: 'edit'; id: string; text: string }
  33. const clients = new Set<ServerWebSocket<unknown>>()
  34. let seq = 0
  35. function nextId() {
  36. return `m${Date.now()}-${++seq}`
  37. }
  38. function broadcast(m: Wire) {
  39. const data = JSON.stringify(m)
  40. for (const ws of clients) if (ws.readyState === 1) ws.send(data)
  41. }
  42. function mime(ext: string) {
  43. const m: Record<string, string> = {
  44. '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
  45. '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
  46. '.pdf': 'application/pdf', '.txt': 'text/plain',
  47. }
  48. return m[ext] ?? 'application/octet-stream'
  49. }
  50. const mcp = new Server(
  51. { name: 'fakechat', version: '0.1.0' },
  52. {
  53. capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
  54. instructions: `The sender reads the fakechat UI, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches the UI.\n\nMessages from the fakechat web UI arrive as <channel source="fakechat" chat_id="web" message_id="...">. If the tag has a file_path attribute, Read that file — it is an upload from the UI. Reply with the reply tool. UI is at http://localhost:${PORT}.`,
  55. },
  56. )
  57. mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
  58. tools: [
  59. {
  60. name: 'reply',
  61. description: 'Send a message to the fakechat UI. Pass reply_to for quote-reply, files for attachments.',
  62. inputSchema: {
  63. type: 'object',
  64. properties: {
  65. text: { type: 'string' },
  66. reply_to: { type: 'string' },
  67. files: { type: 'array', items: { type: 'string' } },
  68. },
  69. required: ['text'],
  70. },
  71. },
  72. {
  73. name: 'edit_message',
  74. description: 'Edit a previously sent message.',
  75. inputSchema: {
  76. type: 'object',
  77. properties: { message_id: { type: 'string' }, text: { type: 'string' } },
  78. required: ['message_id', 'text'],
  79. },
  80. },
  81. ],
  82. }))
  83. mcp.setRequestHandler(CallToolRequestSchema, async req => {
  84. const args = (req.params.arguments ?? {}) as Record<string, unknown>
  85. try {
  86. switch (req.params.name) {
  87. case 'reply': {
  88. const text = args.text as string
  89. const replyTo = args.reply_to as string | undefined
  90. const files = (args.files as string[] | undefined) ?? []
  91. const ids: string[] = []
  92. // Text + files collapse into a single message, matching the client's [filename]-under-text rendering.
  93. mkdirSync(OUTBOX_DIR, { recursive: true })
  94. let file: { url: string; name: string } | undefined
  95. if (files[0]) {
  96. const f = files[0]
  97. const st = statSync(f)
  98. if (st.size > 50 * 1024 * 1024) throw new Error(`file too large: ${f}`)
  99. const ext = extname(f).toLowerCase()
  100. const out = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`
  101. copyFileSync(f, join(OUTBOX_DIR, out))
  102. file = { url: `/files/${out}`, name: basename(f) }
  103. }
  104. const id = nextId()
  105. broadcast({ type: 'msg', id, from: 'assistant', text, ts: Date.now(), replyTo, file })
  106. ids.push(id)
  107. return { content: [{ type: 'text', text: `sent (${ids.join(', ')})` }] }
  108. }
  109. case 'edit_message': {
  110. broadcast({ type: 'edit', id: args.message_id as string, text: args.text as string })
  111. return { content: [{ type: 'text', text: 'ok' }] }
  112. }
  113. default:
  114. return { content: [{ type: 'text', text: `unknown: ${req.params.name}` }], isError: true }
  115. }
  116. } catch (err) {
  117. return { content: [{ type: 'text', text: `${req.params.name}: ${err instanceof Error ? err.message : err}` }], isError: true }
  118. }
  119. })
  120. await mcp.connect(new StdioServerTransport())
  121. function deliver(id: string, text: string, file?: { path: string; name: string }): void {
  122. // file_path goes in meta only — an in-content "[attached — Read: PATH]"
  123. // annotation is forgeable by typing that string into the UI.
  124. void mcp.notification({
  125. method: 'notifications/claude/channel',
  126. params: {
  127. content: text || `(${file?.name ?? 'attachment'})`,
  128. meta: {
  129. chat_id: 'web', message_id: id, user: 'web', ts: new Date().toISOString(),
  130. ...(file ? { file_path: file.path } : {}),
  131. },
  132. },
  133. })
  134. }
  135. Bun.serve({
  136. port: PORT,
  137. hostname: '127.0.0.1',
  138. fetch(req, server) {
  139. const url = new URL(req.url)
  140. if (url.pathname === '/ws') {
  141. if (server.upgrade(req)) return
  142. return new Response('upgrade failed', { status: 400 })
  143. }
  144. if (url.pathname.startsWith('/files/')) {
  145. const f = url.pathname.slice(7)
  146. if (f.includes('..') || f.includes('/')) return new Response('bad', { status: 400 })
  147. try {
  148. return new Response(readFileSync(join(OUTBOX_DIR, f)), {
  149. headers: { 'content-type': mime(extname(f).toLowerCase()) },
  150. })
  151. } catch {
  152. return new Response('404', { status: 404 })
  153. }
  154. }
  155. if (url.pathname === '/upload' && req.method === 'POST') {
  156. return (async () => {
  157. const form = await req.formData()
  158. const id = String(form.get('id') ?? '')
  159. const text = String(form.get('text') ?? '')
  160. const f = form.get('file')
  161. if (!id) return new Response('missing id', { status: 400 })
  162. let file: { path: string; name: string } | undefined
  163. if (f instanceof File && f.size > 0) {
  164. mkdirSync(INBOX_DIR, { recursive: true })
  165. const ext = extname(f.name).toLowerCase() || '.bin'
  166. const path = join(INBOX_DIR, `${Date.now()}${ext}`)
  167. writeFileSync(path, Buffer.from(await f.arrayBuffer()))
  168. file = { path, name: f.name }
  169. }
  170. deliver(id, text, file)
  171. return new Response(null, { status: 204 })
  172. })()
  173. }
  174. if (url.pathname === '/') {
  175. return new Response(HTML, { headers: { 'content-type': 'text/html; charset=utf-8' } })
  176. }
  177. return new Response('404', { status: 404 })
  178. },
  179. websocket: {
  180. open: ws => { clients.add(ws) },
  181. close: ws => { clients.delete(ws) },
  182. message: (_, raw) => {
  183. try {
  184. const { id, text } = JSON.parse(String(raw)) as { id: string; text: string }
  185. if (id && text?.trim()) deliver(id, text.trim())
  186. } catch {}
  187. },
  188. },
  189. })
  190. process.stderr.write(`fakechat: http://localhost:${PORT}\n`)
  191. const HTML = `<!doctype html>
  192. <meta charset="utf-8">
  193. <title>fakechat</title>
  194. <style>
  195. body { font-family: monospace; margin: 0; padding: 1em 1em 7em; }
  196. #log { white-space: pre-wrap; word-break: break-word; }
  197. form { position: fixed; bottom: 0; left: 0; right: 0; padding: 1em; background: #fff; }
  198. #text { width: 100%; box-sizing: border-box; font: inherit; margin-bottom: 0.5em; }
  199. #file { display: none; }
  200. #row { display: flex; gap: 1ch; }
  201. #row button[type=submit] { margin-left: auto; }
  202. </style>
  203. <h3>fakechat</h3>
  204. <pre id=log></pre>
  205. <form id=form>
  206. <textarea id=text rows=2 autocomplete=off autofocus></textarea>
  207. <div id=row>
  208. <button type=button onclick="file.click()">attach</button><input type=file id=file>
  209. <span id=chip></span>
  210. <button type=submit>send</button>
  211. </div>
  212. </form>
  213. <script>
  214. const log = document.getElementById('log')
  215. document.getElementById('file').onchange = e => { const f = e.target.files[0]; chip.textContent = f ? '[' + f.name + ']' : '' }
  216. const form = document.getElementById('form')
  217. const input = document.getElementById('text')
  218. const fileIn = document.getElementById('file')
  219. const chip = document.getElementById('chip')
  220. const msgs = {}
  221. const ws = new WebSocket('ws://' + location.host + '/ws')
  222. ws.onmessage = e => {
  223. const m = JSON.parse(e.data)
  224. if (m.type === 'msg') add(m)
  225. if (m.type === 'edit') { const x = msgs[m.id]; if (x) { x.body.textContent = m.text + ' (edited)' } }
  226. }
  227. let uid = 0
  228. form.onsubmit = e => {
  229. e.preventDefault()
  230. const text = input.value.trim()
  231. const file = fileIn.files[0]
  232. if (!text && !file) return
  233. input.value = ''; fileIn.value = ''; chip.textContent = ''
  234. const id = 'u' + Date.now() + '-' + (++uid)
  235. add({ id, from: 'user', text, file: file ? { url: URL.createObjectURL(file), name: file.name } : undefined })
  236. if (file) {
  237. const fd = new FormData(); fd.set('id', id); fd.set('text', text); fd.set('file', file)
  238. fetch('/upload', { method: 'POST', body: fd })
  239. } else {
  240. ws.send(JSON.stringify({ id, text }))
  241. }
  242. }
  243. function add(m) {
  244. const who = m.from === 'user' ? 'you' : 'bot'
  245. const el = line(who, m.text, m.replyTo, m.file)
  246. log.appendChild(el); scroll()
  247. msgs[m.id] = { body: el.querySelector('.body') }
  248. }
  249. function line(who, text, replyTo, file) {
  250. const div = document.createElement('div')
  251. const t = new Date().toTimeString().slice(0, 8)
  252. const reply = replyTo && msgs[replyTo] ? ' ↳ ' + (msgs[replyTo].body.textContent || '(file)').slice(0, 40) : ''
  253. div.innerHTML = '[' + t + '] <b>' + who + '</b>' + reply + ': <span class=body></span>'
  254. const body = div.querySelector('.body')
  255. body.textContent = text || ''
  256. if (file) {
  257. const indent = 11 + who.length + 2 // '[HH:MM:SS] ' + who + ': '
  258. if (text) body.appendChild(document.createTextNode('\\n' + ' '.repeat(indent)))
  259. const a = document.createElement('a')
  260. a.href = file.url; a.download = file.name; a.textContent = '[' + file.name + ']'
  261. body.appendChild(a)
  262. }
  263. return div
  264. }
  265. function scroll() { window.scrollTo(0, document.body.scrollHeight) }
  266. input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit() } })
  267. </script>
  268. `