docs: 交付文档、维护指引与端到端回归

补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
WangShen007 committed 2026-10-21 21:40:00 +08:00
1 parent 5be5ca3376
commit 308332eb94
216 files changed
+37038

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
// A pretend customer website with the ChatterMate widget embedded the way the
// dashboard's install snippet does it. Everything about the embed comes from the
// query string:
//
// http://127.0.0.1:13000/?widget=<id>
// &config=<json> → window.chattermateConfig (launcher, position, trigger, …)
// &token=<jwt> → window.chattermateToken (token-authenticated widgets)
// &trigger=1 → adds a page button with data-chattermate-open
// &snippet=iframe → the dashboard's iframe snippet instead of the script
//
// It has to be a real server on 127.0.0.1: Chrome's Local Network Access check
// blocks a page Playwright fabricates (page.route) from loading the loader and
// calling the API on loopback.
//
// Run: node services/customer-site/server.ts --port 13000 --api <api url> --loader <loader url>
import { createServer } from 'node:http'
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`)
return i > -1 ? process.argv[i + 1] : fallback
}
const port = Number(arg('port', '13000'))
const apiUrl = arg('api', 'http://127.0.0.1:18000/api/v1')
const loaderUrl = arg('loader', 'http://127.0.0.1:15173/webclient/chattermate.min.js')
interface Embed {
widgetId: string
config: string | null
token: string | null
trigger: boolean
iframe: boolean
}
/** The dashboard's iframe snippet (AgentWidgetTab), classed so the page object finds it. */
function iframeSnippet(widgetId: string): string {
const src = `${apiUrl}/widgets/${encodeURIComponent(widgetId)}/data`
return `<iframe class="chattermate-iframe" src="${src}" width="100%" height="600" frameborder="0" title="AI Assistant" allow="clipboard-write"></iframe>`
}
function page(embed: Embed): string {
if (embed.iframe) {
return `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Acme Store</title></head>
<body>
<h1>Acme Store</h1>
${iframeSnippet(embed.widgetId)}
</body>
</html>`
}
const globals = [
`window.chattermateId = ${JSON.stringify(embed.widgetId)};`,
`window.chattermateBaseUrl = ${JSON.stringify(apiUrl)};`,
// config arrives as JSON text; parse it here so a malformed value fails the request, not the page.
embed.config ? `window.chattermateConfig = ${JSON.stringify(JSON.parse(embed.config))};` : '',
embed.token ? `window.chattermateToken = ${JSON.stringify(embed.token)};` : '',
].filter(Boolean).join('\n ')
return `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Acme Store</title></head>
<body>
<h1>Acme Store</h1>
${embed.trigger ? '<button type="button" data-chattermate-open>Chat with us</button>' : ''}
<script>
${globals}
</script>
<script src="${loaderUrl}"></script>
</body>
</html>`
}
createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
if (url.pathname === '/health') {
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok')
return
}
if (url.pathname !== '/') {
res.writeHead(404).end()
return
}
const params = url.searchParams
let html: string
try {
html = page({
widgetId: params.get('widget') ?? '',
config: params.get('config'),
token: params.get('token'),
trigger: params.get('trigger') === '1',
iframe: params.get('snippet') === 'iframe',
})
} catch {
res.writeHead(400, { 'content-type': 'text/plain' }).end('config is not valid JSON')
return
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html)
}).listen(port, '127.0.0.1', () => console.log(`[customer-site] http://127.0.0.1:${port}/?widget=<id>`))
+95
View File
@@ -0,0 +1,95 @@
// What each provider rejects, from its own published limits — deliberately NOT
// the backend's constants (backend/app/channels/interactive_limits.py), so a
// backend that drifts past a real limit gets the same 400 it would in production.
//
// Each check returns the provider-style error message, or null when the payload
// would be accepted.
type Body = Record<string, unknown> | null
// Provider payloads are walked loosely; typing every Graph/Bot API shape buys nothing here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Loose = any
const len = (value: unknown): number => (typeof value === 'string' ? [...value].length : 0)
const bytes = (value: unknown): number => (typeof value === 'string' ? Buffer.byteLength(value) : 0)
function tooLong(field: string, value: unknown, max: number): string | null {
return len(value) > max ? `${field} is ${len(value)} characters; the limit is ${max}` : null
}
function first(...checks: Array<string | null>): string | null {
return checks.find((c) => c !== null) ?? null
}
// https://developers.facebook.com/docs/whatsapp/cloud-api/messages
function whatsapp(body: Body): string | null {
if (!body || body.messaging_product !== 'whatsapp' || !body.type) return null // receipts, typing
if (body.type === 'text') return tooLong('text.body', (body.text as Body)?.body, 4096)
if (body.type !== 'interactive') return null
const interactive = body.interactive as Record<string, Loose>
const bodyText = tooLong('interactive.body.text', interactive?.body?.text, 1024)
if (interactive?.type === 'button') {
const buttons: Loose[] = interactive.action?.buttons ?? []
if (buttons.length < 1 || buttons.length > 3) return `interactive buttons must number 1-3, got ${buttons.length}`
return first(bodyText, ...buttons.map((b) => first(
tooLong('button title', b.reply?.title, 20),
bytes(b.reply?.id) > 256 ? 'button id is over 256 bytes' : null,
)))
}
if (interactive?.type === 'list') {
const sections: Loose[] = interactive.action?.sections ?? []
const rows = sections.flatMap((s) => s.rows ?? [])
if (sections.length > 10) return `a list allows 10 sections, got ${sections.length}`
if (rows.length < 1 || rows.length > 10) return `a list allows 1-10 rows in total, got ${rows.length}`
return first(bodyText, tooLong('list button', interactive.action?.button, 20), ...rows.map((r) => first(
tooLong('row title', r.title, 24),
tooLong('row description', r.description, 72),
bytes(r.id) > 200 ? 'row id is over 200 bytes' : null,
)))
}
return bodyText
}
// https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies
function pageSend(textMax: number) {
return (body: Body): string | null => {
const message = body?.message as Record<string, Loose> | undefined
if (!message) return null // sender_action (typing)
const replies: Loose[] = message.quick_replies ?? []
if (replies.length > 13) return `quick_replies allows 13, got ${replies.length}`
return first(tooLong('message.text', message.text, textMax), ...replies.map((q) => tooLong('quick reply title', q.title, 20)))
}
}
// https://core.telegram.org/bots/api#sendmessage
function telegram(action: string, body: Body): string | null {
if (action !== 'sendMessage') return null
const keyboard: Loose[] = (body?.reply_markup as Record<string, Loose> | undefined)?.inline_keyboard?.flat() ?? []
return first(
tooLong('text', body?.text, 4096),
...keyboard.map((b) => (bytes(b.callback_data) > 64 ? 'callback_data is over 64 bytes' : null)),
)
}
// https://developers.line.biz/en/reference/messaging-api/#send-push-message
function line(action: string, body: Body): string | null {
if (action !== 'push') return null
const messages: Loose[] = (body?.messages as Loose[]) ?? []
if (messages.length < 1 || messages.length > 5) return `messages must number 1-5, got ${messages.length}`
return first(...messages.map((m) => tooLong('text', m.text, 5000)))
}
/** The provider's rejection for this call, or null. `provider` is the fake's path prefix. */
export function rejection(provider: string, action: string, method: string, body: Body): string | null {
if (method !== 'POST') return null
switch (provider) {
case 'graph': return action === 'messages' ? (body?.messaging_product ? whatsapp(body) : pageSend(2000)(body)) : null
case 'graph-instagram': return action === 'messages' ? pageSend(1000)(body) : null
case 'telegram': return telegram(action, body)
case 'line': return line(action, body)
case 'twilio': return action === 'Messages.json' ? tooLong('Body', body?.Body, 1600) : null
case 'plivo': return action === 'Message' ? tooLong('text', body?.text, 1600) : null
case 'slack': return action === 'chat.postMessage' || action === 'chat.update' ? tooLong('text', body?.text, 40000) : null
default: return null
}
}
+548
View File
@@ -0,0 +1,548 @@
// A recording stand-in for the third-party APIs the backend calls on its own:
// Telegram, Meta Graph (WhatsApp / Messenger / Instagram), Slack, LINE, SMS
// providers, HubSpot, Pipedrive, Jira, Shopify. The backend reaches it through the *_API_BASE overrides in
// .env.e2e (see backend/app/core/external_urls.py); each provider is a path prefix.
//
// Every request is recorded (method, path, query, headers, body, the answer) and
// answered like the provider would:
// - the smallest success the adapter accepts, by default;
// - the provider's own rejection when a payload breaks its published limits
// (limits.ts), so a backend that drifts past a real limit fails here too;
// - a scripted answer — a failure, or a reply for a provider with no built-in
// answers — while one the test registered matches (PUT /__faults).
// Tests then assert on what the backend sent ("the bot replied on Telegram with …")
// and read values the provider would have learned, such as the webhook secret
// Telegram receives in setWebhook.
//
// Where a provider tells the backend who the account is, the answer is derived
// from what the test sent, so every test gets its own account:
// Meta tokens `owns:<id>[,<id>…]:<anything>` own those nodes (pages, numbers, IG
// accounts). Graph refuses such a token a
// node another token owns, and debug_token
// reports its first id as the page
// Meta tokens `…:app=<id>.<secret>:…` were issued by the customer's own Meta
// app: GET /app answers that id and checks
// appsecret_proof against that secret (any
// other token is ChatterMate's app)
// Meta POST /<number> {webhook_configuration} verifies the override callback the way
// Meta does — a GET with hub.verify_token
// that must echo hub.challenge — before
// accepting it
// Meta GET /<node> id = <node>
// LINE GET /info userId derived from the channel access token
// Slack oauth.v2.access team id and bot token derived from the OAuth code
// HubSpot oauth/v1/token an access token derived from the code; introspecting it
// (oauth/v1/access-tokens/<token>) gives a hub id from it
// Pipedrive oauth/token tokens derived from the code, and this fake as the
// company's api_domain; users/me gives a company id
// derived from the token
// Jira authorize consent granted at once: redirects to redirect_uri with a
// fresh code and the state; the token is derived from the
// code and accessible-resources gives a site derived from it
// Shopify orders the customer's order with the number they searched for
// (#1001 if none), shipped with a tracking number
//
// A call's `action` is the provider's method (Telegram, Slack) or else the last
// non-empty path segment (`/Account/<id>/Message/` → `Message`).
//
// Control API (src/fakes/externals.ts):
// GET /__calls?provider=&action=&contains=&since= recorded calls, oldest first;
// contains = substring of path or body
// DELETE /__calls forget every call
// PUT /__faults/:id { provider, action?, contains?, status, body?, times? }
// DELETE /__faults/:id
//
// Run: node services/fake-externals/server.ts --port 14020
import { createHash, createHmac } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { rejection } from './limits.ts'
export interface RecordedCall {
at: string
provider: string
/** Provider-level operation: Telegram/Slack method name, else the last path segment. */
action: string
method: string
path: string
query: Record<string, string>
/** Lower-cased request headers — e.g. authorization, to tell which account sent it. */
headers: Record<string, string>
body: unknown
/** HTTP status the fake answered with. */
status: number
/** What the fake answered — e.g. the message_id Telegram assigned. */
response: unknown
}
export interface Fault {
provider: string
action?: string
/** Only calls whose path or body contains this — scope a fault to one test's account or customer. */
contains?: string
status: number
body?: unknown
/** How many matching calls fail; omitted = until the fault is deleted. */
times?: number
}
const MAX_CALLS = 5000
const calls: RecordedCall[] = []
const faults = new Map<string, Fault>()
/** Every node some `owns:` token has claimed — what other tokens are refused. */
const claimed = new Set<string>()
let counter = 0
class HttpError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function send(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}) {
if (status === 204) {
res.writeHead(status, headers)
res.end()
return
}
res.writeHead(status, { 'content-type': 'application/json', ...headers })
res.end(JSON.stringify(body))
}
async function readRaw(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
return Buffer.concat(chunks).toString('utf8')
}
function parseBody(raw: string, contentType: string): unknown {
if (!raw) return null
if (contentType.includes('application/x-www-form-urlencoded')) return Object.fromEntries(new URLSearchParams(raw))
try {
return JSON.parse(raw)
} catch {
return raw
}
}
function digest(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
/** A stable numeric id per bot token, so reconnecting the same token finds the same bot. */
function botIdFor(token: string): number {
return parseInt(digest(token).slice(0, 10), 16)
}
function bearer(headers: Record<string, string>): string {
return (headers.authorization ?? '').replace(/^Bearer\s+/i, '')
}
// ---------- per-provider answers ----------
interface Call {
provider: string
/** Where the backend reached this fake, e.g. http://127.0.0.1:14020. */
origin: string
action: string
method: string
url: URL
headers: Record<string, string>
body: Record<string, unknown> | null
}
interface Answer {
status: number
body: unknown
headers?: Record<string, string>
}
const ok = (body: unknown): Answer => ({ status: 200, body })
function telegram(action: string, token: string, body: Record<string, unknown> | null): Answer {
switch (action) {
case 'getMe': {
const id = botIdFor(token)
return ok({ ok: true, result: { id, is_bot: true, first_name: 'E2E Bot', username: `e2e_bot_${id}` } })
}
case 'sendMessage':
return ok({ ok: true, result: { message_id: counter, date: Math.floor(Date.now() / 1000), chat: { id: body?.chat_id }, text: body?.text } })
default:
// setWebhook, deleteWebhook, sendChatAction, answerCallbackQuery, editMessageReplyMarkup, …
return ok({ ok: true, result: true })
}
}
/** The node ids a Meta token owns, or null for a token that makes no claim. */
function ownedNodes(token: string): string[] | null {
const match = /^owns:([^:]+):/.exec(token)
if (!match) return null
const nodes = match[1].split(',')
for (const node of nodes) claimed.add(node)
return nodes
}
function graphError(message: string, code = 100): Answer {
return { status: 400, body: { error: { message, type: 'OAuthException', code, fbtrace_id: 'e2e' } } }
}
/** ChatterMate's own Meta app; its secret mirrors META_APP_SECRET in .env.e2e. */
const CHATTERMATE_APP = { id: 'e2e-chattermate-app', secret: 'e2e-meta-app-secret' }
/** The Meta app that issued a token. */
function appOf(token: string): { id: string; secret: string } {
const match = /:app=([^.:]+)\.([^:]+)/.exec(token)
return match ? { id: match[1], secret: match[2] } : CHATTERMATE_APP
}
/** Meta checks an override callback before taking it: the GET must echo the challenge. */
async function overrideCallback(config: Record<string, unknown>): Promise<Answer> {
const uri = String(config.override_callback_uri ?? '')
if (!uri) return ok({ success: true }) // back to the app's own callback
const challenge = `e2e-challenge-${counter}`
const query = new URLSearchParams({ 'hub.mode': 'subscribe', 'hub.verify_token': String(config.verify_token ?? ''), 'hub.challenge': challenge })
const check = await fetch(`${uri}?${query}`, { signal: AbortSignal.timeout(5_000) }).catch(() => null)
if (!check || (await check.text()) !== challenge) {
return graphError('The callback URL or verify token couldn\'t be validated. Please verify the provided information or try again later.', 2200)
}
return ok({ success: true })
}
async function graph({ action, method, url, headers, body }: Call): Promise<Answer> {
if (action === 'app' && method === 'GET') {
const token = bearer(headers)
const app = appOf(token)
const proof = url.searchParams.get('appsecret_proof')
if (proof !== null && proof !== createHmac('sha256', app.secret).update(token).digest('hex')) {
return graphError('Invalid appsecret_proof provided in the API argument')
}
return ok({ id: app.id, name: `E2E app ${app.id}` })
}
if (action === 'debug_token') {
const inspected = ownedNodes(url.searchParams.get('input_token') ?? '')
return ok({ data: { is_valid: Boolean(inspected), type: 'PAGE', profile_id: inspected?.[0] ?? '' } })
}
// `/<provider>/v21.0/<node>[/<edge>]`. A token can't reach a page, number or IG
// account another token owns; anything unclaimed (a customer's PSID, `me`) is fine.
const owned = ownedNodes(bearer(headers))
const node = url.pathname.split('/').filter(Boolean)[2] ?? ''
if (owned && claimed.has(node) && !owned.includes(node)) {
return graphError(`Unsupported request - object with ID '${node}' does not exist, cannot be loaded due to missing permissions, or does not support this operation`)
}
const webhookConfiguration = body?.webhook_configuration
if (method === 'POST' && webhookConfiguration && typeof webhookConfiguration === 'object') {
return overrideCallback(webhookConfiguration as Record<string, unknown>)
}
if (method !== 'GET') {
return ok({ messages: [{ id: `wamid.e2e.${counter}` }], message_id: `m_e2e_${counter}`, recipient_id: body?.recipient ?? null, success: true })
}
return ok({ id: action, name: `E2E ${action}`, username: `e2e_${action}`, data: [] })
}
function answer(call: Call): Answer | Promise<Answer> {
const { provider, action, headers, body } = call
switch (provider) {
case 'graph':
case 'graph-instagram':
return graph(call)
case 'slack':
if (action === 'oauth.v2.access') {
const code = String(body?.code ?? '')
return ok({ ok: true, access_token: `xoxb-e2e-${digest(code).slice(0, 12)}`, bot_user_id: 'U0E2EBOT', team: { id: `T${digest(code).slice(0, 10).toUpperCase()}`, name: 'E2E Workspace' } })
}
return ok({ ok: true, ts: `${Math.floor(Date.now() / 1000)}.${String(counter).padStart(6, '0')}`, channel: body?.channel ?? 'C0E2E' })
case 'line':
// GET /info is the bot's own profile; everything else (push, webhook endpoint) answers an empty 200.
return ok(action === 'info' ? { userId: `U${digest(bearer(headers)).slice(0, 32)}`, displayName: 'E2E LINE Bot', basicId: '@e2e' } : {})
case 'twilio':
return ok({ sid: `SM${String(counter).padStart(32, '0')}`, status: 'queued' })
case 'plivo':
return ok({ message_uuid: [`e2e-${counter}`], api_id: `e2e-${counter}` })
case 'hubspot':
return hubspot(call)
case 'pipedrive-oauth':
return pipedriveOauth(call)
case 'pipedrive':
return pipedrive(call)
case 'jira-auth':
return jiraAuth(call)
case 'jira':
return jira(call)
case 'shopify':
return shopify(call)
default:
return ok({})
}
}
function hubspot({ method, url, body }: Call): Answer {
const path = url.pathname
if (path.endsWith('/oauth/v1/token')) {
const seed = String(body?.code ?? body?.refresh_token ?? '')
return ok({ access_token: `hs-${digest(seed).slice(0, 16)}`, refresh_token: `hs-refresh-${digest(seed).slice(0, 8)}`, expires_in: 1800, token_type: 'bearer' })
}
const introspected = /\/oauth\/v1\/access-tokens\/([^/]+)$/.exec(path)
if (introspected) return ok({ hub_id: parseInt(digest(introspected[1]).slice(0, 7), 16), hub_domain: 'e2e.hubspot.com', token: introspected[1] })
if (path.endsWith('/contacts/batch/upsert')) return ok({ status: 'COMPLETE', results: [{ id: String(counter), new: true }] })
return ok(method === 'GET' ? { results: [] } : { id: String(counter) })
}
/** Insert-ordered, so the oldest entry goes first once a map outgrows `max`. */
function remember<V>(map: Map<string, V>, key: string, value: V, max = 1000) {
map.set(key, value)
if (map.size > max) map.delete(map.keys().next().value as string)
}
// Pipedrive: OAuth on pipedrive-oauth (oauth.pipedrive.com); the API on the
// company's own host, which the token response names — here, this fake.
/** Consent granted at once: back to the app with a fresh code and the state. */
function consentRedirect(url: URL): Answer {
const redirect = url.searchParams.get('redirect_uri')
if (!redirect) return { status: 400, body: { error: 'invalid_request', error_description: 'redirect_uri is required' } }
const back = new URL(redirect)
back.searchParams.set('code', `code-${counter}`)
back.searchParams.set('state', url.searchParams.get('state') ?? '')
return { status: 302, body: {}, headers: { location: back.toString() } }
}
function pipedriveOauth({ action, url, origin, body }: Call): Answer {
if (action === 'authorize') return consentRedirect(url)
if (action === 'revoke') return ok({})
const seed = String(body?.code ?? body?.refresh_token ?? '')
return ok({
access_token: `pd-${digest(seed).slice(0, 16)}`, refresh_token: `pd-refresh-${digest(seed).slice(0, 8)}`,
token_type: 'Bearer', expires_in: 3600, scope: 'base,contacts:full,leads:full', api_domain: `${origin}/pipedrive`,
})
}
function pipedrive({ method, url, headers, body }: Call): Answer {
const path = url.pathname.replace(/^\/pipedrive/, '')
if (path === '/api/v1/users/me') {
return ok({ success: true, data: { id: 1, company_id: parseInt(digest(bearer(headers)).slice(0, 7), 16), company_name: 'E2E Pipedrive Co' } })
}
// Nothing exists yet: searches find nobody, and every create gets a fresh id.
if (path.endsWith('/search')) return ok({ success: true, data: { items: [] } })
if (path === '/api/v1/leads' && method === 'GET') return ok({ success: true, data: [] })
if (method === 'POST' && path === '/api/v1/leads') return ok({ success: true, data: { id: `00000000-0000-4000-8000-${String(counter).padStart(12, '0')}`, title: body?.title } })
if (method === 'POST' || method === 'PATCH') return ok({ success: true, data: { id: counter, ...(body ?? {}) } })
return { status: 404, body: { success: false, error: `e2e fake has no answer for ${method} ${path}` } }
}
// Jira Cloud: OAuth on jira-auth (auth.atlassian.com), the REST API on jira (api.atlassian.com).
function jiraAuth({ action, url, body }: Call): Answer {
if (action === 'authorize') return consentRedirect(url)
// oauth/token, for both the code grant and a refresh.
const seed = String(body?.code ?? body?.refresh_token ?? '')
return ok({ access_token: `jira-${digest(seed).slice(0, 16)}`, refresh_token: `jira-refresh-${digest(seed).slice(0, 8)}`, token_type: 'Bearer', expires_in: 3600, scope: 'read:jira-work write:jira-work read:jira-user offline_access' })
}
const JIRA_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'].map((name, i) => ({ id: String(i + 1), name }))
const JIRA_ISSUE_TYPES = [
{ id: '10001', name: 'Task', subtask: false, description: 'A small, distinct piece of work.' },
{ id: '10002', name: 'Sub-task', subtask: true, description: 'A part of a task.' },
]
const jiraIssues = new Map<string, { key: string; fields: Record<string, unknown> }>()
function jiraNotFound(message: string): Answer {
return { status: 404, body: { errorMessages: [message], errors: {} } }
}
function jira({ method, url, headers, body }: Call): Answer {
if (url.pathname.endsWith('/oauth/token/accessible-resources')) {
const site = digest(bearer(headers)).slice(0, 12)
return ok([{ id: `cloud-${site}`, url: `https://e2e-${site}.atlassian.net`, name: `e2e-${site}`, scopes: ['read:jira-work', 'write:jira-work'], avatarUrl: '' }])
}
// /jira/ex/jira/<cloud id>/rest/api/3/<resource>
const resource = /\/ex\/jira\/[^/]+\/rest\/api\/3\/(.+)$/.exec(url.pathname)?.[1] ?? ''
if (resource === 'project') return ok([{ id: '10000', key: 'SUP', name: 'Support' }])
if (resource.startsWith('project/')) return ok({ id: '10000', key: resource.slice('project/'.length), name: 'Support', issueTypes: JIRA_ISSUE_TYPES })
if (resource === 'priority') return ok(JIRA_PRIORITIES)
if (resource === 'issue/createmeta') {
return ok({ projects: [{ key: url.searchParams.get('projectKeys'), issuetypes: [{ id: url.searchParams.get('issuetypeIds'), fields: { summary: {}, description: {}, priority: {} } }] }] })
}
if (resource === 'issue' && method === 'POST') {
const fields = (body?.fields ?? {}) as { project?: { key?: string } }
const key = `${fields.project?.key ?? 'SUP'}-${counter}`
remember(jiraIssues, key, { key, fields: { ...fields, status: { name: 'To Do' } } })
return { status: 201, body: { id: String(counter), key, self: `${url.origin}${url.pathname}/${counter}` } }
}
const issueKey = /^issue\/([^/]+)$/.exec(resource)?.[1]
if (issueKey) {
const issue = jiraIssues.get(issueKey)
if (!issue) return jiraNotFound('Issue does not exist or you do not have permission to see it.')
if (method === 'PUT') {
Object.assign(issue.fields, (body?.fields ?? {}) as Record<string, unknown>)
return { status: 204, body: null }
}
return ok({ id: issueKey, ...issue })
}
return jiraNotFound(`e2e fake has no answer for ${method} ${resource}`)
}
// Shopify Admin API: /shopify/<shop>.myshopify.com/admin/...
interface ShopifyOrder { id: string; number: string; email: string }
const shopifyOrders = new Map<string, ShopifyOrder>()
function money(amount: string) {
return { shopMoney: { amount, currencyCode: 'USD' } }
}
function shopifyOrderNode(order: ShopifyOrder) {
return {
id: `gid://shopify/Order/${order.id}`,
name: `#${order.number}`,
email: order.email,
phone: null,
createdAt: '2026-10-01T10:00:00Z',
processedAt: '2026-10-01T10:00:00Z',
updatedAt: '2026-10-02T10:00:00Z',
cancelledAt: null,
cancelReason: null,
displayFinancialStatus: 'PAID',
displayFulfillmentStatus: 'FULFILLED',
subtotalLineItemsQuantity: 1,
currentSubtotalPriceSet: money('40.00'),
currentTotalPriceSet: money('45.00'),
originalTotalPriceSet: money('45.00'),
customer: { id: `gid://shopify/Customer/${order.id}`, firstName: 'E2E', lastName: 'Customer', email: order.email, phone: null },
shippingAddress: { address1: '1 Test Street', address2: null, city: 'Testville', provinceCode: 'CA', zip: '94000', country: 'United States', phone: null, name: 'E2E Customer' },
fulfillments: [{ status: 'SUCCESS', trackingInfo: [{ company: 'UPS', number: `1ZE2E${order.id}`, url: `https://tracking.e2e.test/1ZE2E${order.id}` }] }],
lineItems: { edges: [{ node: { id: `gid://shopify/LineItem/${order.id}`, title: 'E2E Mug', name: 'E2E Mug', quantity: 1, originalUnitPriceSet: money('40.00'), variant: { id: 'gid://shopify/ProductVariant/1', title: 'Default', sku: 'E2E-MUG', image: null } } }] },
}
}
function shopify({ action, body }: Call): Answer {
if (action === 'access_token') {
const seed = String(body?.subject_token ?? body?.code ?? '')
return ok({ access_token: `shpat_${digest(seed).slice(0, 24)}`, scope: 'read_orders,read_products' })
}
const query = String(body?.query ?? '')
const variables = (body?.variables ?? {}) as Record<string, unknown>
if (query.includes('query SearchOrders')) {
// `email:<email>[ AND name:<number>]`, as the backend builds it.
const search = String(variables.query ?? '')
const email = /email:(\S+)/.exec(search)?.[1] ?? ''
const number = /name:(\S+)/.exec(search)?.[1] ?? '1001'
const order = { id: String(parseInt(digest(`${email}#${number}`).slice(0, 8), 16)), number, email }
remember(shopifyOrders, order.id, order)
return ok({ data: { orders: { edges: email ? [{ node: shopifyOrderNode(order) }] : [], pageInfo: { hasNextPage: false, endCursor: null } } } })
}
if (query.includes('query GetOrder')) {
const order = shopifyOrders.get(String(variables.id ?? '').split('/').at(-1) ?? '')
return ok({ data: { order: order ? shopifyOrderNode(order) : null } })
}
return ok({ errors: [{ message: 'e2e fake has no answer for this query' }] })
}
/** How each provider says "bad request", so the backend's error handling sees the real shape. */
function providerRejection(provider: string, message: string): Answer {
switch (provider) {
case 'graph':
case 'graph-instagram': return graphError(message)
case 'telegram': return { status: 400, body: { ok: false, error_code: 400, description: `Bad Request: ${message}` } }
case 'slack': return ok({ ok: false, error: 'msg_too_long', detail: message })
case 'twilio': return { status: 400, body: { code: 21617, message, status: 400 } }
default: return { status: 400, body: { message, error: message } }
}
}
// ---------- faults ----------
function matchingFault(provider: string, action: string, haystack: string): Fault | undefined {
for (const [id, fault] of faults) {
if (fault.provider !== provider) continue
if (fault.action && fault.action !== action) continue
if (fault.contains && !haystack.includes(fault.contains)) continue
if (fault.times !== undefined) {
fault.times -= 1
if (fault.times <= 0) faults.delete(id)
}
return fault
}
return undefined
}
// ---------- http ----------
async function route(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? '/', 'http://fake-externals')
const path = url.pathname
if (path === '/health') return send(res, 200, { status: 'ok', calls: calls.length, faults: faults.size })
if (path === '/__calls' && req.method === 'GET') {
const q = url.searchParams
const since = q.get('since')
const contains = q.get('contains')
return send(res, 200, calls.filter((c) =>
(!q.get('provider') || c.provider === q.get('provider'))
&& (!q.get('action') || c.action === q.get('action'))
&& (!since || c.at >= since)
&& (!contains || `${c.path}\n${JSON.stringify(c.body)}`.includes(contains))))
}
if (path === '/__calls' && req.method === 'DELETE') {
calls.length = 0
return send(res, 200, { ok: true })
}
const faultPath = /^\/__faults\/([\w.-]+)$/.exec(path)
if (faultPath && req.method === 'PUT') {
const fault = parseBody(await readRaw(req), 'application/json') as Fault | null
if (!fault?.provider || !fault.status) throw new HttpError(400, 'a fault needs provider and status')
faults.set(faultPath[1], fault)
return send(res, 200, { ok: true })
}
if (faultPath && req.method === 'DELETE') {
faults.delete(faultPath[1])
return send(res, 200, { ok: true })
}
const [, provider = '', ...rest] = path.split('/')
const raw = await readRaw(req)
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v ?? '']))
const body = parseBody(raw, headers['content-type'] ?? '') as Record<string, unknown> | null
const method = req.method ?? 'GET'
counter += 1
let action = rest.filter(Boolean).at(-1) ?? ''
let token = ''
if (provider === 'telegram') {
// /telegram/bot<token>/<method>
token = (rest[0] ?? '').replace(/^bot/, '')
action = rest[1] ?? ''
}
const fault = matchingFault(provider, action, `${path}\n${raw}`)
const rejected = fault ? null : rejection(provider, action, method, body)
let result: Answer
if (fault) result = { status: fault.status, body: fault.body ?? {} }
else if (rejected) result = providerRejection(provider, rejected)
else if (provider === 'telegram') result = telegram(action, token, body)
else result = await answer({ provider, origin: `http://${headers.host ?? '127.0.0.1'}`, action, method, url, headers, body })
calls.push({
at: new Date().toISOString(),
provider, action, method, path,
query: Object.fromEntries(url.searchParams),
headers, body,
status: result.status,
response: result.body,
})
if (calls.length > MAX_CALLS) calls.splice(0, calls.length - MAX_CALLS)
return send(res, result.status, result.body, result.headers)
}
const portArg = process.argv.indexOf('--port')
const port = Number(portArg > -1 ? process.argv[portArg + 1] : 14020)
createServer((req, res) => {
route(req, res).catch((err: unknown) => {
const status = err instanceof HttpError ? err.status : 500
const message = err instanceof Error ? err.message : String(err)
console.error(`[fake-externals] ${req.method} ${req.url} -> ${status}: ${message}`)
send(res, status, { error: message })
})
}).listen(port, '127.0.0.1', () => console.log(`[fake-externals] listening on http://127.0.0.1:${port}`))
+60
View File
@@ -0,0 +1,60 @@
"""Checks the fake LLM against the backend's real agno code path.
The e2e suite only works if agno accepts what the fake returns. This runs the same
calls the backend makes — the AI-setup key check and a ChatAgent-style structured
reply — through agno against a running fake, so an agno upgrade that changes the
request or response shape fails here with a clear message instead of as flaky e2e
tests. CI runs it right after the stack starts.
cd backend && PYTHONPATH=. ENV_FILE=../e2e/.env.e2e NO_ENTERPRISE=1 \\
python ../e2e/services/fake-llm/contract_check.py [base_url]
"""
import asyncio
import json
import sys
import urllib.request
from agno.agent import Agent
from app.models.schemas.chat import ChatResponse
from app.utils.agno_utils import create_model, test_model_api_key
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:14010/v1"
CONTROL = BASE_URL.removesuffix("/v1")
SCENARIO = "contract-check"
def put_scenario() -> None:
spec = {"rules": [
{"match": {"source": "human", "flags": "i"},
"reply": {"message": "Connecting you.", "fields": {"transfer_to_human": True}}},
{"reply": {"message": "Happy to help!"}},
]}
req = urllib.request.Request(f"{CONTROL}/__scenarios/{SCENARIO}", data=json.dumps(spec).encode(),
method="PUT", headers={"content-type": "application/json"})
urllib.request.urlopen(req).read()
async def main() -> int:
put_scenario()
assert await test_model_api_key("e2e-key", "OPENAI_COMPATIBLE", "e2e-fake", base_url=BASE_URL), \
"AI-setup key check failed against the fake"
model = create_model("OPENAI_COMPATIBLE", "e2e-key", "e2e-fake", max_tokens=1000, base_url=BASE_URL)
agent = Agent(model=model, instructions=f"Contract check. [[e2e:{SCENARIO}]]",
response_model=ChatResponse, structured_outputs=True, system_message_role="system")
handover = (await agent.arun("I want a human")).content
assert isinstance(handover, ChatResponse), f"structured reply not parsed: {handover!r}"
assert handover.transfer_to_human is True and handover.message == "Connecting you.", handover
plain = (await agent.arun("hello")).content
assert plain.transfer_to_human is False and plain.message == "Happy to help!", plain
print("fake LLM contract OK")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+77
View File
@@ -0,0 +1,77 @@
// Wire types shared by the fake LLM server and the test-side client (src/fakes/llm.ts).
// Type-only: the server imports this with `import type`, so Node's type stripping
// needs nothing at runtime.
/** A serialisable RegExp. */
export interface Pattern {
source: string
flags: string
}
/**
* What the model "says" for one matched turn.
*
* - `message` / `fields` fill a structured (json_schema) reply — e.g. ChatAgent's
* ChatResponse. `fields` overlays any schema field: transfer_to_human, create_ticket, …
* - `text` is used for plain-text requests (the transfer agent, the key check).
* Falls back to `message`.
*/
export interface Reply {
message?: string
fields?: Record<string, unknown>
text?: string
/** Token counts to report, for metering. Default 1/1. */
usage?: { prompt_tokens: number; completion_tokens: number }
}
/** How the turn goes wrong instead of (or before) answering. */
export interface Fault {
/** Answer with this HTTP status and an OpenAI-style error instead of a completion. */
status?: number
message?: string
/** Wait this long first — past AGENT_RUN_TIMEOUT to exercise the backend's timeout. */
delayMs?: number
/** Return content that is not the JSON the request's schema asks for. */
malformed?: boolean
}
export interface ToolCall {
name: string
arguments: Record<string, unknown>
}
export interface ScenarioRule {
/** Tested against the last user message. Omitted = matches anything (otherwise). */
match?: Pattern
/** Ask the backend to run a tool first; `reply` is sent after the tool result comes back. */
toolCall?: ToolCall
reply: Reply
fault?: Fault
}
export interface ScenarioSpec {
/**
* Some backend calls never see the agent's instructions (the transfer agent's prompt
* has no `[[e2e:<id>]]` marker), so a scenario can also match on the unique agent name.
*/
agentName?: string
rules: ScenarioRule[]
}
export interface RecordedRequest {
at: string
system: string
lastUser: string
lastRole: string
/** 'json_schema:<name>' | 'json_object' | null */
responseFormat: string | null
tools: string[]
/** Contents of the tool-result messages in this request — e.g. what knowledge search returned. */
toolResults: string[]
/** What the fake answered: 'tool_call:<name>' | 'structured' | 'text' | 'error:<status>' | 'malformed' */
answeredWith: string
/** The model the backend asked for. */
model: string
/** The last 6 characters of the API key it sent — which key the org's config used. */
keySuffix: string
}
+285
View File
@@ -0,0 +1,285 @@
// A deterministic stand-in for an OpenAI-compatible model, for e2e tests.
//
// The backend's agents are pointed here (AI config model_type OPENAI_COMPATIBLE,
// base_url http://127.0.0.1:14010/v1). Each test registers a *scenario* — scripted
// replies keyed by a `[[e2e:<id>]]` marker in its agent's instructions or by the
// agent's unique name — so parallel tests in one org never see each other's script.
//
// It speaks only what ChatterMate uses: non-streaming /v1/chat/completions, with or
// without a strict json_schema response_format (ChatAgent's ChatResponse), and tool
// calls. Anything else fails loudly instead of returning something plausible.
//
// Control API (used by src/fakes/llm.ts):
// PUT /__scenarios/:id register/replace a ScenarioSpec
// GET /__scenarios/:id/requests what the backend sent for that scenario
// DELETE /__scenarios forget everything (manual use only: scenario
// ids are unique per test, and a reset would
// wipe any other run sharing this fake)
//
// An API key starting with `e2e-invalid` is rejected with 401, like a real provider.
// A rule can also fail on purpose (fault: status, delay, malformed output).
//
// Run: node services/fake-llm/server.ts --port 14010 (Node >= 22.18 strips the types)
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { Fault, RecordedRequest, Reply, ScenarioRule, ScenarioSpec } from './protocol.ts'
type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
// JSON Schema is walked generically (properties, $ref, anyOf, …); typing every
// keyword buys nothing here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JsonSchema = { [key: string]: any }
interface ChatMessage {
role: string
content?: string | Array<{ type: string; text?: string }> | null
tool_calls?: unknown[]
}
interface ChatRequest {
model?: string
messages: ChatMessage[]
stream?: boolean
response_format?: { type: string; json_schema?: { name?: string; schema?: JsonSchema } }
tools?: Array<{ type: string; function: { name: string } }>
}
const MARKER = /\[\[e2e:([\w-]+)\]\]/
const DEFAULT_SCENARIO = '__default'
const DEFAULT_REPLY: Reply = { message: 'Default reply from the e2e fake LLM.', text: 'OK' }
const MAX_RECORDED = 50
// Oldest scenarios are forgotten past this, so a fake that outlives many runs stays small.
const MAX_SCENARIOS = 5000
const scenarios = new Map<string, ScenarioSpec>()
const recorded = new Map<string, RecordedRequest[]>()
// ---------- request helpers ----------
function textOf(content: ChatMessage['content']): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) return content.map((part) => part.text ?? '').join('\n')
return ''
}
function selectScenario(req: ChatRequest): string {
const all = req.messages.map((m) => textOf(m.content)).join('\n')
const marker = MARKER.exec(all)
if (marker && scenarios.has(marker[1])) return marker[1]
for (const [id, spec] of scenarios) {
if (spec.agentName && all.includes(spec.agentName)) return id
}
return DEFAULT_SCENARIO
}
function selectRule(spec: ScenarioSpec | undefined, lastUser: string): ScenarioRule | undefined {
if (!spec) return undefined
return spec.rules.find((rule) => !rule.match || new RegExp(rule.match.source, rule.match.flags).test(lastUser))
}
// ---------- structured output ----------
function resolveRef(schema: JsonSchema, root: JsonSchema): JsonSchema {
if (typeof schema.$ref !== 'string') return schema
const path = schema.$ref.replace(/^#\//, '').split('/')
return path.reduce((node: JsonSchema, key: string) => node?.[key], root) ?? {}
}
function isNullable(schema: JsonSchema): boolean {
if (Array.isArray(schema.type)) return schema.type.includes('null')
const variants = schema.anyOf ?? schema.oneOf
return Array.isArray(variants) && variants.some((v: JsonSchema) => v.type === 'null')
}
/** The minimal value that satisfies `schema` — the base every structured reply is built on. */
function defaultFor(rawSchema: JsonSchema, root: JsonSchema): Json {
const schema = resolveRef(rawSchema, root)
if ('default' in schema) return schema.default as Json
if (isNullable(schema)) return null
if (Array.isArray(schema.enum)) return schema.enum[0] as Json
const variants = schema.anyOf ?? schema.oneOf ?? schema.allOf
if (Array.isArray(variants) && variants.length) return defaultFor(variants[0], root)
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
switch (type) {
case 'string': return ''
case 'boolean': return false
case 'number':
case 'integer': return 0
case 'array': return []
case 'object':
default: {
const out: { [key: string]: Json } = {}
for (const [key, prop] of Object.entries<JsonSchema>(schema.properties ?? {})) out[key] = defaultFor(prop, root)
return out
}
}
}
function structuredContent(req: ChatRequest, reply: Reply): string {
const schema = req.response_format?.json_schema?.schema
const base = schema ? (defaultFor(schema, schema) as { [key: string]: Json }) : {}
const message = reply.message ?? DEFAULT_REPLY.message
return JSON.stringify({ ...base, message, ...(reply.fields as { [key: string]: Json } | undefined) })
}
// ---------- completion ----------
function completion(req: ChatRequest, message: { content: string | null; tool_calls?: unknown[] }, usage = { prompt_tokens: 1, completion_tokens: 1 }) {
return {
id: `chatcmpl-e2e-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: req.model ?? 'e2e-fake',
choices: [{
index: 0,
message: { role: 'assistant', ...message },
finish_reason: message.tool_calls ? 'tool_calls' : 'stop',
}],
usage: { ...usage, total_tokens: usage.prompt_tokens + usage.completion_tokens },
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/** A scripted failure: an OpenAI-style error status, or garbage where JSON was asked for. */
function faulted(req: ChatRequest, fault: Fault, record: RecordedRequest) {
if (fault.status) {
record.answeredWith = `error:${fault.status}`
throw new HttpError(fault.status, fault.message ?? 'The e2e fake LLM failed on purpose.')
}
record.answeredWith = 'malformed'
return completion(req, { content: '{"message": "this reply is cut off' })
}
async function answer(req: ChatRequest, authorization: string) {
const scenarioId = selectScenario(req)
const lastUserMsg = [...req.messages].reverse().find((m) => m.role === 'user')
const lastUser = textOf(lastUserMsg?.content)
const lastRole = req.messages.at(-1)?.role ?? ''
const rule = selectRule(scenarios.get(scenarioId), lastUser)
const tools = (req.tools ?? []).map((t) => t.function.name)
const format = req.response_format
const record: RecordedRequest = {
at: new Date().toISOString(),
system: req.messages.filter((m) => m.role === 'system' || m.role === 'developer').map((m) => textOf(m.content)).join('\n'),
lastUser,
lastRole,
responseFormat: format ? (format.type === 'json_schema' ? `json_schema:${format.json_schema?.name ?? ''}` : format.type) : null,
tools,
toolResults: req.messages.filter((m) => m.role === 'tool').map((m) => textOf(m.content)),
answeredWith: '',
model: req.model ?? '',
keySuffix: authorization.replace(/^Bearer\s+/i, '').slice(-6),
}
const list = recorded.get(scenarioId) ?? []
list.push(record)
recorded.set(scenarioId, list.slice(-MAX_RECORDED))
if (rule?.fault?.delayMs) await sleep(rule.fault.delayMs)
if (rule?.fault && (rule.fault.status || rule.fault.malformed)) return faulted(req, rule.fault, record)
const usage = rule?.reply.usage
let body
if (rule?.toolCall && lastRole !== 'tool') {
if (!tools.includes(rule.toolCall.name)) {
throw new HttpError(422, `scenario '${scenarioId}' wants tool '${rule.toolCall.name}' but the request only offers [${tools.join(', ')}]`)
}
record.answeredWith = `tool_call:${rule.toolCall.name}`
body = completion(req, {
content: null,
tool_calls: [{
id: `call_e2e_${Date.now()}`,
type: 'function',
function: { name: rule.toolCall.name, arguments: JSON.stringify(rule.toolCall.arguments) },
}],
})
} else if (format && format.type !== 'text') {
record.answeredWith = 'structured'
body = completion(req, { content: structuredContent(req, rule?.reply ?? DEFAULT_REPLY) }, usage)
} else {
const reply = rule?.reply ?? DEFAULT_REPLY
record.answeredWith = 'text'
body = completion(req, { content: reply.text ?? reply.message ?? DEFAULT_REPLY.text! }, usage)
}
return body
}
// ---------- http ----------
class HttpError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const raw = Buffer.concat(chunks).toString('utf8')
if (!raw) return {}
try {
return JSON.parse(raw)
} catch {
throw new HttpError(400, 'body is not JSON')
}
}
function send(res: ServerResponse, status: number, body: unknown) {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
async function route(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? '/', 'http://fake-llm')
const path = url.pathname.replace(/\/+$/, '')
const scenarioPath = /^\/__scenarios\/([\w-]+)(\/requests)?$/.exec(path)
if (req.method === 'GET' && path === '/health') return send(res, 200, { status: 'ok', scenarios: scenarios.size })
if (req.method === 'GET' && path === '/v1/models') return send(res, 200, { object: 'list', data: [{ id: 'e2e-fake', object: 'model' }] })
if (req.method === 'POST' && path === '/v1/chat/completions') {
// Lets a test exercise the "invalid API key" path: the backend live-tests keys.
if (/^Bearer e2e-invalid/.test(req.headers.authorization ?? '')) {
throw new HttpError(401, 'Incorrect API key provided.')
}
const body = (await readJson(req)) as ChatRequest
if (body.stream) throw new HttpError(400, 'the e2e fake LLM does not stream — ChatterMate never asks it to; if it does now, extend services/fake-llm')
if (!Array.isArray(body.messages)) throw new HttpError(400, 'messages[] is required')
return send(res, 200, await answer(body, req.headers.authorization ?? ''))
}
if (scenarioPath && req.method === 'PUT' && !scenarioPath[2]) {
const spec = (await readJson(req)) as ScenarioSpec
if (!Array.isArray(spec.rules)) throw new HttpError(400, 'rules[] is required')
scenarios.set(scenarioPath[1], spec)
recorded.delete(scenarioPath[1])
while (scenarios.size > MAX_SCENARIOS) {
const oldest = scenarios.keys().next().value!
scenarios.delete(oldest)
recorded.delete(oldest)
}
return send(res, 200, { ok: true })
}
if (scenarioPath && req.method === 'GET' && scenarioPath[2]) return send(res, 200, recorded.get(scenarioPath[1]) ?? [])
if (req.method === 'DELETE' && path === '/__scenarios') {
scenarios.clear()
recorded.clear()
return send(res, 200, { ok: true })
}
throw new HttpError(404, `no route for ${req.method} ${url.pathname}`)
}
const portArg = process.argv.indexOf('--port')
const port = Number(portArg > -1 ? process.argv[portArg + 1] : process.env.FAKE_LLM_PORT ?? 14010)
createServer((req, res) => {
route(req, res).catch((err: unknown) => {
const status = err instanceof HttpError ? err.status : 500
const message = err instanceof Error ? err.message : String(err)
console.error(`[fake-llm] ${req.method} ${req.url} -> ${status}: ${message}`)
send(res, status, { error: { message, type: 'e2e_fake_llm_error' } })
})
}).listen(port, '127.0.0.1', () => console.log(`[fake-llm] listening on http://127.0.0.1:${port}`))
+88
View File
@@ -0,0 +1,88 @@
// A real MCP server (streamable HTTP, stateless) for the MCP-connector journeys:
// the backend connects to it exactly as it would to a customer's server, lists
// its tools and calls them when the (fake) model asks.
//
// POST /mcp the MCP endpoint
// GET /__requests the headers each MCP request arrived with, oldest first
// DELETE /__requests forget them
//
// Tools: lookup_order(order_id) → "Order <id>: shipped on 2 May via DHL".
//
// Run: node services/fake-mcp/server.ts --port 14030
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
interface SeenRequest {
at: string
method: string
authorization: string | null
rpcMethod: string | null
}
const seen: SeenRequest[] = []
function mcpServer(): McpServer {
const server = new McpServer({ name: 'e2e-orders', version: '1.0.0' })
server.registerTool('lookup_order', {
description: 'Look up an order by its id and return its shipping status.',
inputSchema: { order_id: z.string().describe('The order id, e.g. A123') },
}, async ({ order_id }) => ({
content: [{ type: 'text', text: `Order ${order_id}: shipped on 2 May via DHL` }],
}))
return server
}
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const raw = Buffer.concat(chunks).toString('utf8')
return raw ? JSON.parse(raw) : undefined
}
function send(res: ServerResponse, status: number, body: unknown) {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
async function route(req: IncomingMessage, res: ServerResponse) {
const path = new URL(req.url ?? '/', 'http://fake-mcp').pathname
if (path === '/health') return send(res, 200, { status: 'ok' })
if (path === '/__requests' && req.method === 'GET') return send(res, 200, seen)
if (path === '/__requests' && req.method === 'DELETE') {
seen.length = 0
return send(res, 200, { ok: true })
}
if (path !== '/mcp') return send(res, 404, { error: `no route for ${path}` })
if (req.method !== 'POST') return send(res, 405, { jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null })
const body = await readJson(req)
seen.push({
at: new Date().toISOString(),
method: req.method,
authorization: req.headers.authorization ?? null,
rpcMethod: (body as { method?: string } | undefined)?.method ?? null,
})
// Stateless: a fresh server and transport per request, as the SDK recommends.
const server = mcpServer()
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
res.on('close', () => {
void transport.close()
void server.close()
})
await server.connect(transport)
await transport.handleRequest(req, res, body)
}
const portArg = process.argv.indexOf('--port')
const port = Number(portArg > -1 ? process.argv[portArg + 1] : 14030)
createServer((req, res) => {
route(req, res).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
console.error(`[fake-mcp] ${req.method} ${req.url} -> 500: ${message}`)
if (!res.headersSent) send(res, 500, { error: message })
})
}).listen(port, '127.0.0.1', () => console.log(`[fake-mcp] listening on http://127.0.0.1:${port}/mcp`))