test(telegram): add MCP App Funnel proof fixture (#111238)

This commit is contained in:
Jason (Json)
2026-07-19 00:14:36 -06:00
committed by GitHub
parent 25a8b33ff5
commit baa8b9b24f
7 changed files with 491 additions and 19 deletions
@@ -101,6 +101,16 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
4. Decide what Telegram message, mock model response, command, callback, button,
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
maintainer guidance, not as a replacement for reading the PR.
For an MCP App channel-action proof, use the trusted runner's
`--mcp-app-fixture` option with a Tailscale-capable Crabbox provider and send
`mcp app conformance qa check`. This starts the pinned official-SDK fixture
and publishes the candidate Gateway through its real Funnel lifecycle. The
native Telegram button must open the fixture showing `ready`. Click both
`Call app tool` and `Read resource`, then capture `companion-called` and
`resource-ok`.
Reopen that same Telegram button after its ticket expires and capture the
expired state. Do not substitute Control UI, transcript, curl, or a newly
minted button for any part of that path.
5. Create detached worktrees under
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then
@@ -0,0 +1,75 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const appUri = "ui://conformance/app";
const appModuleUrl =
"https://cdn.jsdelivr.net/npm/@modelcontextprotocol/ext-apps@1.7.4/dist/src/app-with-deps.js";
const appHtml = `<!doctype html>
<meta charset="utf-8" />
<button id="call-app">Call app tool</button>
<button id="read-resource">Read resource</button>
<output id="initialized">pending</output>
<output id="app-tool"></output>
<output id="resource"></output>
<script type="module">
import { App } from ${JSON.stringify(appModuleUrl)};
const write = (id, value) => { document.getElementById(id).textContent = value; };
const app = new App({ name: "OpenClaw conformance fixture", version: "1.0.0" });
app.onerror = (error) => console.error("mcp-conformance-app", error);
document.getElementById("call-app").onclick = async () => {
try {
const value = await app.callServerTool({ name: "app_companion", arguments: {} });
write("app-tool", JSON.stringify(value.structuredContent ?? value));
} catch (error) { write("app-tool", "denied:" + error); }
};
document.getElementById("read-resource").onclick = async () => {
try {
const value = await app.readServerResource({ uri: "data://conformance/value" });
write("resource", JSON.stringify(value));
} catch (error) { write("resource", "denied:" + error); }
};
await app.connect();
write("initialized", "ready");
</script>`;
const server = new McpServer({ name: "mcp-app-conformance", version: "1.0.0" });
const show = server.tool("show", "Show the conformance app", async () => ({
content: [{ type: "text", text: "initial-result" }],
structuredContent: { value: "initial-result" },
}));
show.update({ _meta: { ui: { resourceUri: appUri } } });
const appOnly = server.tool("app_companion", "App-only companion", async () => ({
content: [{ type: "text", text: "companion-called" }],
structuredContent: { value: "companion-called" },
}));
appOnly.update({ _meta: { ui: { visibility: ["app"] } } });
server.registerResource(
"conformance_app",
appUri,
{ mimeType: "text/html;profile=mcp-app" },
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "text/html;profile=mcp-app",
text: appHtml,
_meta: { ui: { csp: { resourceDomains: ["https://cdn.jsdelivr.net"] } } },
},
],
}),
);
server.registerResource(
"conformance_data",
"data://conformance/value",
{ mimeType: "text/plain" },
async (uri) => ({
contents: [{ uri: uri.href, mimeType: "text/plain", text: "resource-ok" }],
}),
);
await server.connect(new StdioServerTransport());
+22
View File
@@ -289,6 +289,23 @@ function mcpCodeModeApiFileEvents(body, bodyText) {
);
}
function mcpAppConformanceEvents(body, bodyText) {
const allText = collectText(body).join("\n");
if (!/mcp app conformance qa check/i.test(allText)) {
return null;
}
const toolOutput = collectFunctionCallOutputText(body);
if (!toolOutput) {
if (!hasDeclaredTool(bodyText, "fixture__show")) {
return null;
}
return toolCallEvents("fixture__show", {});
}
return /initial-result/.test(toolOutput)
? responseEvents("MCP_APP_CONFORMANCE_READY")
: responseEvents("MCP_APP_CONFORMANCE_FAIL");
}
const server = http.createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -334,6 +351,11 @@ const server = http.createServer((req, res) => {
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const appEvents = mcpAppConformanceEvents(body, bodyText);
if (appEvents) {
writeResponsesEvents(res, body.stream, appEvents);
return;
}
const codeModeEvents = mcpCodeModeApiFileEvents(body, bodyText);
if (codeModeEvents) {
writeResponsesEvents(res, body.stream, codeModeEvents);
+211 -19
View File
@@ -42,6 +42,7 @@ type CrabboxInspect = {
sshPort?: string;
sshUser?: string;
state?: string;
tailscale?: unknown;
};
type Options = {
@@ -65,6 +66,7 @@ type Options = {
idleTimeout: string;
keepBox: boolean;
leaseId?: string;
mcpAppFixture: boolean;
mockResponseText: string;
mockPort: number;
outputDir: string;
@@ -92,6 +94,12 @@ type Options = {
userDriverScript: string;
};
type FunnelBridge = {
proxyPath: string;
tunnelLog: string;
tunnelPid: number;
};
type LocalSut = {
configPath: string;
drained: {
@@ -108,6 +116,7 @@ type LocalSut = {
workspace: string;
gateway: ChildProcess;
gatewayLog: string;
funnelBridge?: FunnelBridge;
};
type SessionFile = {
@@ -138,6 +147,7 @@ type SessionFile = {
stateDir: string;
tempRoot: string;
workspace: string;
funnelBridge?: FunnelBridge;
};
outputDir: string;
recorder: {
@@ -199,6 +209,7 @@ function usageText() {
" --id <cbx_id> Reuse an existing Crabbox desktop lease.",
" --keep-box Leave the Crabbox lease running for VNC debugging.",
" --mock-response-file <path> Text returned by the mock model.",
" --mcp-app-fixture Configure the pinned MCP App fixture through a Crabbox Funnel.",
" --output-dir <path> Artifact directory under the repo.",
" --message-id <id> Telegram message id for proof-view deep link.",
" --preview-crop telegram-window Create a side-by-side friendly Telegram-window GIF.",
@@ -302,6 +313,7 @@ export function parseArgs(argvInput: string[]): Options {
gatewayPort: 19_879,
idleTimeout: "60m",
keepBox: false,
mcpAppFixture: false,
mockResponseText: "OPENCLAW_E2E_OK",
mockPort: 19_882,
outputDir: path.join(DEFAULT_OUTPUT_ROOT, createTelegramProofRunId()),
@@ -375,6 +387,8 @@ export function parseArgs(argvInput: string[]): Options {
opts.mockPort = parseTcpPort(readValue(), "--mock-port");
} else if (arg === "--mock-response-file") {
opts.mockResponseText = fs.readFileSync(resolveRepoPath(process.cwd(), readValue()), "utf8");
} else if (arg === "--mcp-app-fixture") {
opts.mcpAppFixture = true;
} else if (arg === "--message-id") {
opts.messageId = String(parsePositiveInteger(readValue(), "--message-id"));
} else if (arg === "--output-dir") {
@@ -445,6 +459,12 @@ export function parseArgs(argvInput: string[]): Options {
if (command === "publish" && !opts.publishPr) {
throw new Error("publish requires --pr.");
}
if (opts.mcpAppFixture && command !== "start") {
throw new Error("--mcp-app-fixture is available only for start sessions.");
}
if (opts.mcpAppFixture && opts.leaseId) {
throw new Error("--mcp-app-fixture requires a fresh lifecycle-owned Crabbox lease.");
}
return opts;
}
@@ -530,12 +550,22 @@ function mockServerEnv(params: { mockPort: number; mockResponseText: string; req
};
}
function gatewayEnv(params: { configPath: string; stateDir: string; sutToken: string }) {
function gatewayEnv(params: {
configPath: string;
gatewayPassword?: string;
stateDir: string;
sutToken: string;
tailscaleProxyDir?: string;
}) {
return {
...childProcessBaseEnv(),
OPENAI_API_KEY: "sk-openclaw-e2e-mock",
OPENCLAW_CONFIG_PATH: params.configPath,
...(params.gatewayPassword ? { OPENCLAW_GATEWAY_PASSWORD: params.gatewayPassword } : {}),
OPENCLAW_STATE_DIR: params.stateDir,
...(params.tailscaleProxyDir
? { PATH: `${params.tailscaleProxyDir}${path.delimiter}${process.env.PATH ?? ""}` }
: {}),
TELEGRAM_BOT_TOKEN: params.sutToken,
};
}
@@ -1104,11 +1134,13 @@ function telegramResultObject(value: unknown, label: string): JsonObject {
return value as JsonObject;
}
function writeSutConfig(params: {
export function writeSutConfig(params: {
gatewayPort: number;
groupId: string;
mcpAppFixture?: boolean;
mockPort: number;
outputDir: string;
repoRoot?: string;
testerId: string;
}) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tg-crabbox-sut-"));
@@ -1157,7 +1189,39 @@ function writeSutConfig(params: {
replyToMode: "first",
},
},
gateway: { auth: { mode: "none" }, bind: "loopback", mode: "local", port: params.gatewayPort },
gateway: params.mcpAppFixture
? {
auth: {
mode: "password",
password: {
id: "OPENCLAW_GATEWAY_PASSWORD",
provider: "default",
source: "env",
},
},
bind: "loopback",
mode: "local",
port: params.gatewayPort,
tailscale: { mode: "funnel", resetOnExit: true },
}
: { auth: { mode: "none" }, bind: "loopback", mode: "local", port: params.gatewayPort },
...(params.mcpAppFixture
? {
mcp: {
servers: {
fixture: {
args: [
path.join(
params.repoRoot ?? process.cwd(),
"scripts/e2e/mcp-app-conformance-server.mjs",
),
],
command: process.execPath,
},
},
},
}
: {}),
messages: { groupChat: { visibleReplies: "automatic" } },
models: {
providers: {
@@ -1308,10 +1372,12 @@ export async function recordProbeVideo(params: {
}
async function startLocalSutDaemon(params: {
funnelBridge?: FunnelBridge;
gatewayPort: number;
groupId: string;
mockResponseText: string;
mockPort: number;
mcpAppFixture?: boolean;
outputDir: string;
sutToken: string;
testerId: string;
@@ -1319,6 +1385,7 @@ async function startLocalSutDaemon(params: {
}) {
const drained = await drainSutUpdates(params.sutToken);
const config = writeSutConfig(params);
const gatewayPassword = params.mcpAppFixture ? randomUUID() : undefined;
const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
const mockLog = path.join(params.outputDir, "mock-openai.log");
const gatewayLog = path.join(params.outputDir, "gateway.log");
@@ -1337,7 +1404,14 @@ async function startLocalSutDaemon(params: {
}
await waitForLog(mockLog, /mock-openai listening/u, "mock-openai", 10_000);
const gatewayEnvVars = gatewayEnv({ ...config, sutToken: params.sutToken });
const gatewayEnvVars = gatewayEnv({
...config,
gatewayPassword,
sutToken: params.sutToken,
tailscaleProxyDir: params.funnelBridge
? path.dirname(params.funnelBridge.proxyPath)
: undefined,
});
const gatewaySpec = createOpenClawGatewaySpawnSpec({
env: gatewayEnvVars,
gatewayPort: params.gatewayPort,
@@ -1364,6 +1438,7 @@ async function startLocalSutDaemon(params: {
mockLog,
mockPid,
requestLog,
funnelBridge: params.funnelBridge,
};
} catch (error) {
killPidTree(gatewayPid);
@@ -1376,24 +1451,34 @@ function extractLeaseId(output: string) {
return output.match(/\b(?:cbx_[a-f0-9]+|tbx_[A-Za-z0-9_-]+)\b/u)?.[0];
}
export function createCrabboxWarmupArgs(
opts: Pick<
Options,
"crabboxClass" | "idleTimeout" | "mcpAppFixture" | "provider" | "target" | "ttl"
>,
) {
return [
"warmup",
"--provider",
opts.provider,
"--target",
opts.target,
"--desktop",
"--browser",
"--class",
opts.crabboxClass,
"--idle-timeout",
opts.idleTimeout,
"--ttl",
opts.ttl,
...(opts.mcpAppFixture ? ["--tailscale"] : []),
];
}
async function warmupCrabbox(opts: Options, root: string) {
const result = await runCommand({
command: opts.crabboxBin,
args: [
"warmup",
"--provider",
opts.provider,
"--target",
opts.target,
"--desktop",
"--browser",
"--class",
opts.crabboxClass,
"--idle-timeout",
opts.idleTimeout,
"--ttl",
opts.ttl,
],
args: createCrabboxWarmupArgs(opts),
cwd: root,
stdio: "inherit",
});
@@ -1613,6 +1698,93 @@ async function sshRun(
});
}
export function renderTailscaleSshProxy(params: { gatewayPort: number; inspect: CrabboxInspect }) {
const ssh = sshArgs(params.inspect);
return `#!/usr/bin/env node
import { spawnSync } from "node:child_process";
const args = process.argv.slice(2);
const port = ${JSON.stringify(String(params.gatewayPort))};
const allowed =
(args.length === 1 && args[0] === "--version") ||
(args.length === 2 && args[0] === "status" && args[1] === "--json") ||
(args.length === 4 && args[0] === "funnel" && args[1] === "--bg" && args[2] === "--yes" && args[3] === port) ||
(args.length === 2 && args[0] === "funnel" && args[1] === "reset");
if (!allowed) {
process.stderr.write("unsupported proof Tailscale command\\n");
process.exit(64);
}
const quote = (value) => "'" + value.replaceAll("'", "'\\\\''") + "'";
const remoteCommand = ["tailscale", ...args].map(quote).join(" ");
const result = spawnSync("ssh", ${JSON.stringify([...ssh.base, ssh.target])}.concat(remoteCommand), {
stdio: "inherit",
});
process.exit(result.status ?? 1);
`;
}
async function startTailscaleFunnelBridge(params: {
gatewayPort: number;
inspect: CrabboxInspect;
localRoot: string;
}) {
if (!params.inspect.tailscale) {
throw new Error("MCP App fixture proof requires a Tailscale-enabled Crabbox lease.");
}
// Keep the SUT local while letting its real Gateway lifecycle own Funnel on
// the Tailscale-enabled desktop lease; no Tailscale credential leaves Crabbox.
const proxyPath = path.join(params.localRoot, "tailscale");
await writeExecutable(
proxyPath,
renderTailscaleSshProxy({ gatewayPort: params.gatewayPort, inspect: params.inspect }),
);
const tunnelLog = path.join(params.localRoot, "gateway-funnel-tunnel.log");
const ssh = sshArgs(params.inspect);
const tunnelPid = spawnDaemon({
args: [
...ssh.base,
"-o",
"ExitOnForwardFailure=yes",
"-N",
"-R",
`127.0.0.1:${params.gatewayPort}:127.0.0.1:${params.gatewayPort}`,
ssh.target,
],
command: "ssh",
cwd: params.localRoot,
env: childProcessBaseEnv(),
logPath: tunnelLog,
});
if (!tunnelPid) {
throw new Error("Gateway Funnel reverse tunnel did not start.");
}
await sleep(500);
try {
process.kill(tunnelPid, 0);
} catch {
throw new Error(`Gateway Funnel reverse tunnel exited early.\n${readLogTail(tunnelLog)}`);
}
return { proxyPath, tunnelLog, tunnelPid };
}
async function stopTailscaleFunnelBridge(
root: string,
bridge: Pick<FunnelBridge, "proxyPath" | "tunnelPid">,
) {
try {
// Explicit reset is the backstop when Gateway shutdown loses its async
// resetOnExit cleanup; the public route must not outlive this fresh lease.
await runCommand({
args: ["funnel", "reset"],
command: bridge.proxyPath,
cwd: root,
timeoutMs: 30_000,
});
} finally {
killPidTree(bridge.tunnelPid);
}
}
export function renderRemoteSetup(params: { tdlibSha256?: string; tdlibUrl?: string }) {
const tdlibSha256 = shellQuote(params.tdlibSha256 ?? "");
const tdlibUrl = shellQuote(params.tdlibUrl ?? "");
@@ -2212,6 +2384,7 @@ async function startSession(root: string, opts: Options, outputDir: string) {
let leaseId = opts.leaseId;
let createdLease = false;
let localSut: Awaited<ReturnType<typeof startLocalSutDaemon>> | undefined;
let funnelBridge: Awaited<ReturnType<typeof startTailscaleFunnelBridge>> | undefined;
try {
credential = await leaseCredential({ localRoot, opts, root });
const sut = opts.sutUsername
@@ -2223,6 +2396,13 @@ async function startSession(root: string, opts: Options, outputDir: string) {
createdLease = true;
}
const inspect = await inspectCrabbox(opts, root, leaseId);
if (opts.mcpAppFixture) {
funnelBridge = await startTailscaleFunnelBridge({
gatewayPort: opts.gatewayPort,
inspect,
localRoot,
});
}
await writeRemoteSessionScripts({
inspect,
localRoot,
@@ -2232,10 +2412,12 @@ async function startSession(root: string, opts: Options, outputDir: string) {
sutUsername: sut.username,
});
localSut = await startLocalSutDaemon({
funnelBridge,
gatewayPort: opts.gatewayPort,
groupId: credential.groupId,
mockResponseText: opts.mockResponseText,
mockPort: opts.mockPort,
mcpAppFixture: opts.mcpAppFixture,
outputDir,
repoRoot: root,
sutToken: credential.sutToken,
@@ -2288,6 +2470,9 @@ async function startSession(root: string, opts: Options, outputDir: string) {
} catch (error) {
killPidTree(localSut?.gatewayPid);
killPidTree(localSut?.mockPid);
if (funnelBridge) {
await stopTailscaleFunnelBridge(root, funnelBridge).catch(() => {});
}
if (credential) {
await releaseCredential(root, opts, credential.leaseFile).catch(() => {});
}
@@ -2537,6 +2722,13 @@ async function finishSession(root: string, opts: Options, outputDir: string) {
} finally {
killPidTree(session.localSut.gatewayPid);
killPidTree(session.localSut.mockPid);
if (session.localSut.funnelBridge) {
await stopTailscaleFunnelBridge(root, session.localSut.funnelBridge).catch(
(error: unknown) => {
summary.funnelResetError = error instanceof Error ? error.message : String(error);
},
);
}
await terminateDesktopSession();
await releaseCredential(root, opts, session.credential.leaseFile).catch((error: unknown) => {
summary.credentialReleaseError = error instanceof Error ? error.message : String(error);
@@ -158,6 +158,40 @@ describe("mock OpenAI response markers", () => {
}
});
});
it("drives the MCP App fixture tool before returning the visible marker", async () => {
await withMockServer(mockOpenAiPath, {}, async (baseUrl) => {
const first = await fetch(`${baseUrl}/v1/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
input: [{ content: "mcp app conformance qa check", role: "user" }],
stream: false,
tools: [{ name: "fixture__show", parameters: { type: "object" }, type: "function" }],
}),
});
const firstBody = await first.json();
expect(firstBody.output?.[0]).toMatchObject({
arguments: "{}",
name: "fixture__show",
type: "function_call",
});
const second = await fetch(`${baseUrl}/v1/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
input: [
{ content: "mcp app conformance qa check", role: "user" },
{ output: "initial-result", type: "function_call_output" },
],
stream: false,
}),
});
const secondBody = await second.json();
expect(secondBody.output?.[0]?.content?.[0]?.text).toBe("MCP_APP_CONFORMANCE_READY");
});
});
});
describe("e2e mock and config helper numeric limits", () => {
@@ -333,6 +333,10 @@ describe("Mantis Telegram Desktop proof workflow", () => {
expect(prompt).toContain("$OPENCLAW_TELEGRAM_USER_PROOF_CMD");
expect(prompt).toContain("do not run\n `pnpm qa:telegram-user:crabbox` directly");
expect(prompt).toContain("Let `start` return or fail on its\n own");
expect(prompt).toContain("`--mcp-app-fixture` option");
expect(prompt).toContain("mcp app conformance qa check");
expect(prompt).toContain("`companion-called` and\n `resource-ok`");
expect(prompt).toContain("Reopen that same Telegram button after its ticket expires");
expect(prompt).toContain(
"Use a long\n command timeout for `start`, `send`, `view`, and `finish`",
);
@@ -5,10 +5,13 @@ import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
COMMAND_TIMEOUT_MS,
createCrabboxWarmupArgs,
createOpenClawGatewaySpawnSpec,
parseArgs,
readLogTail,
@@ -19,11 +22,13 @@ import {
renderRemoteProbe,
renderRemoteSetup,
renderSelectDesktopChat,
renderTailscaleSshProxy,
runCommand,
signalCommandTree,
stageFullSessionArtifacts,
startLocalSut,
waitForLog,
writeSutConfig,
} from "../../scripts/e2e/telegram-user-crabbox-proof.ts";
import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs";
@@ -192,6 +197,136 @@ describe("telegram user Crabbox proof log polling", () => {
);
});
it("enables the pinned MCP App fixture only when explicitly requested", () => {
expect(parseArgs(["start", "--mcp-app-fixture"]).mcpAppFixture).toBe(true);
expect(parseArgs([]).mcpAppFixture).toBe(false);
const ordinaryArgs = createCrabboxWarmupArgs(parseArgs([]));
const fixtureArgs = createCrabboxWarmupArgs(parseArgs(["start", "--mcp-app-fixture"]));
expect(ordinaryArgs).not.toContain("--tailscale");
expect(fixtureArgs).toContain("--tailscale");
expect(() => parseArgs(["--mcp-app-fixture"])).toThrow(
"--mcp-app-fixture is available only for start sessions.",
);
expect(() => parseArgs(["start", "--mcp-app-fixture", "--id", "cbx_reused"])).toThrow(
"--mcp-app-fixture requires a fresh lifecycle-owned Crabbox lease.",
);
});
it("writes an isolated Funnel and official MCP App fixture config", () => {
const configRoot = writeSutConfig({
gatewayPort: 19042,
groupId: "group",
mcpAppFixture: true,
mockPort: 19043,
outputDir: makeTempDir(),
repoRoot: "/repo",
testerId: "tester",
});
tempDirs.push(configRoot.tempRoot);
const config = JSON.parse(fs.readFileSync(configRoot.configPath, "utf8"));
expect(config.gateway).toMatchObject({
auth: {
mode: "password",
password: { id: "OPENCLAW_GATEWAY_PASSWORD", source: "env" },
},
tailscale: { mode: "funnel", resetOnExit: true },
});
expect(config.mcp.servers.fixture).toEqual({
args: ["/repo/scripts/e2e/mcp-app-conformance-server.mjs"],
command: process.execPath,
});
expect(JSON.stringify(config)).not.toContain("companion-called");
expect(JSON.stringify(config)).not.toContain("resource-ok");
});
it("pins the browser fixture SDK and exposes only the required app capabilities", () => {
const fixture = fs.readFileSync("scripts/e2e/mcp-app-conformance-server.mjs", "utf8");
const uiPackage = JSON.parse(fs.readFileSync("ui/package.json", "utf8"));
expect(uiPackage.dependencies["@modelcontextprotocol/ext-apps"]).toBe("1.7.4");
expect(fixture).toContain(
`@modelcontextprotocol/ext-apps@${uiPackage.dependencies["@modelcontextprotocol/ext-apps"]}`,
);
expect(fixture).toContain('server.tool("app_companion"');
expect(fixture).toContain('visibility: ["app"]');
expect(fixture).toContain('"data://conformance/value"');
expect(fixture).toContain('text: "resource-ok"');
});
it("serves the fixture view, app-only tool, and resource over official MCP stdio", async () => {
const transport = new StdioClientTransport({
args: ["scripts/e2e/mcp-app-conformance-server.mjs"],
command: process.execPath,
cwd: process.cwd(),
stderr: "pipe",
});
const client = new Client({ name: "telegram-proof-fixture-test", version: "1.0.0" });
try {
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools.find((tool) => tool.name === "show")?._meta).toMatchObject({
ui: { resourceUri: "ui://conformance/app" },
});
expect(tools.tools.find((tool) => tool.name === "app_companion")?._meta).toMatchObject({
ui: { visibility: ["app"] },
});
expect(await client.callTool({ arguments: {}, name: "app_companion" })).toMatchObject({
structuredContent: { value: "companion-called" },
});
expect(await client.readResource({ uri: "data://conformance/value" })).toMatchObject({
contents: [{ text: "resource-ok" }],
});
} finally {
await Promise.allSettled([client.close(), transport.close()]);
}
});
posixIt("limits the Funnel bridge proxy to the Gateway lifecycle commands", () => {
const root = makeTempDir();
const sshPath = path.join(root, "ssh");
const argvPath = path.join(root, "ssh-argv.json");
const proxyPath = path.join(root, "tailscale");
writeExecutable(
sshPath,
`#!/usr/bin/env node\nimport fs from "node:fs";\nfs.writeFileSync(process.env.ARGV_PATH, JSON.stringify(process.argv.slice(2)));\n`,
);
writeExecutable(
proxyPath,
renderTailscaleSshProxy({
gatewayPort: 19042,
inspect: {
host: "proof.example",
sshKey: "/tmp/proof-key",
sshPort: "2222",
sshUser: "proof",
},
}),
);
const env = {
...process.env,
ARGV_PATH: argvPath,
PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
};
const allowed = spawnSync(proxyPath, ["funnel", "--bg", "--yes", "19042"], {
encoding: "utf8",
env,
});
expect(allowed.status).toBe(0);
expect(JSON.parse(fs.readFileSync(argvPath, "utf8"))).toContain(
"'tailscale' 'funnel' '--bg' '--yes' '19042'",
);
const rejected = spawnSync(proxyPath, ["serve", "--bg", "19042"], {
encoding: "utf8",
env,
});
expect(rejected.status).toBe(64);
expect(rejected.stderr).toContain("unsupported proof Tailscale command");
});
it("reads only the requested log tail", () => {
const logPath = path.join(makeTempDir(), "gateway.log");
fs.writeFileSync(logPath, `${"old\n".repeat(2000)}ready\n`, "utf8");