From ba80ea8da6b26cd0ea45193ff843921647417025 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Wed, 23 Sep 2026 20:52:24 -0400 Subject: [PATCH] Add watched takes of any length obs-take-start preflights, starts recording, waits for OBS to confirm, and starts the take monitor; obs-take-mark adds a chapter to the file and the take log; obs-take-status reports the take so far; obs-take-stop stops, checks the file, and writes the take log. obs-record-clip now runs on the same code, so a take and a clip report the same way. While a take records, tools that switch the profile or scene collection or change output, video, recording directory, or stream service settings refuse to run, including through obs-call-request and obs-batch. Stopping is never refused, and lock: false turns the lock off. With OBS_MCP_CONFIRM_LIVE, obs-take-stop asks first. The record-demo prompt takes a list of steps and records them as a take with a chapter per step. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Vjn8uBEa4mJshaQU3pFuqX --- README.md | 22 +- src/tools/__snapshots__/tool-contract.json | 92 +++++ src/tools/confirm.ts | 1 + src/tools/index.ts | 11 +- src/tools/prompts.ts | 14 +- src/tools/resources.test.ts | 13 + src/tools/take-monitor.ts | 7 + src/tools/takes.test.ts | 174 +++++++++ src/tools/takes.ts | 427 +++++++++++++++++++++ src/tools/tool-inventory.test.ts | 4 +- src/tools/workflows.ts | 134 +------ test/support/tool-count.ts | 2 +- 12 files changed, 761 insertions(+), 140 deletions(-) create mode 100644 src/tools/takes.test.ts create mode 100644 src/tools/takes.ts diff --git a/README.md b/README.md index ed45025..e8e3c41 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/Tom-R-Main/OBS-MCP/actions/workflows/ci.yml/badge.svg)](https://github.com/Tom-R-Main/OBS-MCP/actions/workflows/ci.yml) -OBS MCP gives MCP clients a current, inspectable interface to OBS Studio. It exposes 159 tools for scenes, sources, audio, transitions, filters, recording, streaming, canvases, and the rest of the OBS WebSocket v5 request surface. +OBS MCP gives MCP clients a current, inspectable interface to OBS Studio. It exposes 163 tools for scenes, sources, audio, transitions, filters, recording, streaming, canvases, and the rest of the OBS WebSocket v5 request surface. The server is built against MCP 2026-07-28 and still accepts legacy 2025-era clients. MCP discovery stays available when OBS is closed, and the server reconnects in the background when OBS returns. @@ -106,7 +106,7 @@ The server never prints the password. Connection and protocol diagnostics are wr ## Tool surface -The 159 tools are organized around the OBS protocol rather than a smaller opinionated workflow: +The 163 tools are organized around the OBS protocol rather than a smaller opinionated workflow: - server status, version information, statistics, hotkeys, and studio mode - scenes, groups, sources, filters, and scene items @@ -116,6 +116,7 @@ The 159 tools are organized around the OBS protocol rather than a smaller opinio - canvases, screenshots, profiles, scene collections, and persistent data - a recording preflight (`obs-preflight`) that catches the conditions under which OBS silently refuses or botches a recording - `obs-record-clip`, which preflights, records up to 50 seconds with chapter markers, stops, and checks the file's length and audio with ffprobe/ffmpeg when they are installed. While it records, it watches the take (see [Watching a take](#watching-a-take)) +- `obs-take-start`, `obs-take-mark`, `obs-take-status`, and `obs-take-stop` for a watched recording of any length, with a chapter per step (see [Watching a take](#watching-a-take)) - `obs-capture-window` (macOS), which points a `screen_capture` input at a window or app by name, silences it, and fits it to the canvas - input and transform changes that return the resulting settings, size, and an optional screenshot, warning when a source renders at 0×0 - protocol description, the guarded generic request fallback, and `obs-batch`, which sends several requests in one message: in order, one per rendered frame (so a change to two sources lands on the same frame), or all at once, with `Sleep` between them @@ -138,14 +139,14 @@ Every tool is registered by default. Clients load each registered tool's definit | `media` | media input playback | | `filters` | source filters | | `transitions` | transitions, overrides, the T-Bar | -| `record` | recording, chapters, `obs-preflight`, and `obs-record-clip` | +| `record` | recording, chapters, `obs-preflight`, `obs-record-clip`, and the `obs-take-*` tools | | `stream` | streaming and captions | | `outputs` | virtual camera, replay buffer, and generic outputs | | `config` | profiles, scene collections, video settings, persistent data | | `ui` | studio mode, dialogs, projectors | | `protocol` | `obs-describe-request`, the generic `obs-call-request`, and `obs-batch` | -`core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (81 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 38-tool inspection-only server. The server refuses to start when a group or tool name is unknown. +`core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (85 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 39-tool inspection-only server. The server refuses to start when a group or tool name is unknown. ### Resources and prompts @@ -165,7 +166,7 @@ Two prompts appear as slash commands in clients that show MCP prompts: `record-d ### Confirming live actions -With `OBS_MCP_CONFIRM_LIVE=true`, `obs-stop-stream`, `obs-toggle-stream`, `obs-stop-record`, and `obs-toggle-record` ask before they run, as do `obs-call-request` and `obs-batch` when they carry one of those requests. Clients that support elicitation (Claude Code, VS Code, Cursor) show the user a confirmation. Other clients get an error asking the model to get the user's agreement and call again with `confirm: true`. These tools are also marked destructive, so clients that ask before destructive tools already prompt without this setting. +With `OBS_MCP_CONFIRM_LIVE=true`, `obs-stop-stream`, `obs-toggle-stream`, `obs-stop-record`, `obs-toggle-record`, and `obs-take-stop` ask before they run, as do `obs-call-request` and `obs-batch` when they carry one of those requests. Clients that support elicitation (Claude Code, VS Code, Cursor) show the user a confirmation. Other clients get an error asking the model to get the user's agreement and call again with `confirm: true`. These tools are also marked destructive, so clients that ask before destructive tools already prompt without this setting. ## Where it fits @@ -189,6 +190,15 @@ Pay particular attention to approvals for tools that: ### Watching a take +`obs-record-clip` records up to 50 seconds in one call. For anything longer, or a demo whose steps happen while it records, use a take: + +1. `obs-take-start` runs the preflight, starts recording, and waits for OBS to confirm. Only one take runs at a time. +2. `obs-take-mark` adds a named chapter before each step, both in the file (Hybrid MP4/MOV) and in the take log. +3. `obs-take-status` reports what the take has seen so far. +4. `obs-take-stop` stops recording, checks the file, and reports. + +While a take records, tools that would switch the profile or scene collection, or change output, video, recording directory, or stream service settings, refuse to run, including through `obs-call-request` and `obs-batch`. Stopping and pausing are never refused. Start with `lock: false` to allow those changes. + While a take records, the server watches OBS instead of waiting for the file: - **Audio**: it subscribes to OBS's `InputVolumeMeters` events for the length of the take and records each input's loudest level after volume and mute. An input on a recorded track that rises above −60 dBFS is logged, and with `expectSilent` it is a problem. Muted inputs and inputs on unrecorded tracks are not. @@ -226,7 +236,7 @@ Test the file users will actually install with: npm run test:package ``` -That command creates `dist/obs-studio.mcpb`, extracts it into a temporary directory, validates its manifest and source contents, checks all 159 tool definitions, and calls the extracted server through MCP against fake OBS. +That command creates `dist/obs-studio.mcpb`, extracts it into a temporary directory, validates its manifest and source contents, checks all 163 tool definitions, and calls the extracted server through MCP against fake OBS. ### Live OBS tests diff --git a/src/tools/__snapshots__/tool-contract.json b/src/tools/__snapshots__/tool-contract.json index 2c2ede9..e757946 100644 --- a/src/tools/__snapshots__/tool-contract.json +++ b/src/tools/__snapshots__/tool-contract.json @@ -6945,6 +6945,98 @@ "openWorldHint": false } }, + { + "name": "obs-take-mark", + "title": "Mark Take", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Chapter name" + } + }, + "required": [ + "name" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "obs-take-start", + "title": "Start Take", + "inputSchema": { + "type": "object", + "properties": { + "expectSilent": { + "default": false, + "description": "Refuse to start with audible inputs, and treat audio during the take as a problem", + "type": "boolean" + }, + "minFreeDiskMb": { + "default": 1024, + "description": "Minimum free disk space in MB", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "lock": { + "default": true, + "description": "Refuse profile, scene collection, and output setting changes until the take stops", + "type": "boolean" + }, + "chapter": { + "description": "Name of a chapter to add at the start", + "type": "string", + "minLength": 1 + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "obs-take-status", + "title": "Take Status", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "obs-take-stop", + "title": "Stop Take", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, { "name": "obs-test-connection", "title": "Test OBS Connection", diff --git a/src/tools/confirm.ts b/src/tools/confirm.ts index e1c48e6..71c4146 100644 --- a/src/tools/confirm.ts +++ b/src/tools/confirm.ts @@ -19,6 +19,7 @@ export const LIVE_ACTIONS: Readonly> = { "obs-toggle-stream": "Toggle the live stream? If it is live, this ends the broadcast.", "obs-stop-record": "Stop the recording?", "obs-toggle-record": "Toggle recording? If it is recording, this ends the take.", + "obs-take-stop": "Stop the recording?", }; /** OBS requests that end a live broadcast or a take, with the question to ask. */ diff --git a/src/tools/index.ts b/src/tools/index.ts index 1e11cf0..ad8f92d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -24,6 +24,7 @@ import * as protocolExtensions from "./protocol-extensions.js"; import * as protocol from "./protocol.js"; import * as preflight from "./preflight.js"; import * as workflows from "./workflows.js"; +import * as takes from "./takes.js"; import * as resources from "./resources.js"; import * as prompts from "./prompts.js"; import { withStructuredToolResults } from "./results.js"; @@ -48,6 +49,7 @@ const MODULES: ReadonlyArray<[ToolGroup, { initialize(server: McpServer, client: ["protocol", protocol], ["record", preflight], ["record", workflows], + ["record", takes], ]; /** @@ -67,9 +69,11 @@ export function initialize( client: OBSWebSocketClient, { filter = ALL_TOOLS, confirmLive = liveConfirmationEnabled(), resourcesAndPrompts = true }: InitializeOptions = {}, ): RegisteredTool[] { - // Registration passes through: group filter -> live confirmation -> structured results -> server. + // Registration passes through: group filter -> live confirmation -> take lock -> structured results -> server. + // At call time the take lock runs first, so a locked call never asks the user. const structuredServer = withStructuredToolResults(server); - const confirmingServer = confirmLive ? withLiveConfirmation(structuredServer) : structuredServer; + const lockingServer = takes.withTakeLock(structuredServer, client); + const confirmingServer = confirmLive ? withLiveConfirmation(lockingServer) : lockingServer; const registry: RegisteredTool[] = []; for (const [group, module] of MODULES) { module.initialize(scopedServer(confirmingServer, group, filter, registry), client); @@ -99,5 +103,6 @@ export { protocolExtensions, protocol, preflight, - workflows + workflows, + takes }; diff --git a/src/tools/prompts.ts b/src/tools/prompts.ts index ed4a5fc..45148a2 100644 --- a/src/tools/prompts.ts +++ b/src/tools/prompts.ts @@ -23,10 +23,11 @@ export function initialize(server: McpServer): void { argsSchema: z.object({ window: z.string().describe("Part of the window title or app name to capture, e.g. \"ChatGPT\""), scene: z.string().default("Demo").describe("Scene to record from"), - seconds: z.string().default("30").describe("Length of the take in seconds (up to 50 in one call)"), + seconds: z.string().default("30").describe("Length of a hands-off take in seconds; ignored when steps are given"), + steps: z.string().optional().describe("Steps to perform on screen, one per line; each becomes a chapter"), }), }, - ({ window, scene, seconds }) => userMessage([ + ({ window, scene, seconds, steps }) => userMessage([ `Record a silent demo of the "${window}" window in the OBS scene "${scene}".`, "", `1. If the scene "${scene}" does not exist, create it with obs-create-scene and switch to it.`, @@ -34,7 +35,14 @@ export function initialize(server: McpServer): void { + "silent true, fit true, includeScreenshot true. If it lists several matches, ask me which one.", "3. Look at the screenshot and the reported size. If the capture renders at 0×0 or shows the wrong window, stop and tell me.", "4. Call obs-preflight with expectSilent true. Fix every failure, or explain it to me if you cannot.", - `5. Call obs-record-clip with durationSeconds ${seconds} and expectSilent true, then report the file path and the verification result.`, + ...(steps + ? [ + "5. Call obs-take-start with expectSilent true. For each step below, call obs-take-mark with the step's name, " + + "then perform it. If obs-take-status or a tool result reports a warning, tell me. Call obs-take-stop " + + "after the last step, then report the file path, the chapters, and the verification result.", + ...steps.split("\n").map((step) => step.trim()).filter(Boolean).map((step) => ` - ${step}`), + ] + : [`5. Call obs-record-clip with durationSeconds ${seconds} and expectSilent true, then report the file path and the verification result.`]), "Do not change output settings with obs-set-profile-parameter without asking: OBS needs a restart to apply them.", ].join("\n")), ); diff --git a/src/tools/resources.test.ts b/src/tools/resources.test.ts index 9aaa87f..c0546eb 100644 --- a/src/tools/resources.test.ts +++ b/src/tools/resources.test.ts @@ -152,4 +152,17 @@ describe("prompts", () => { expect(text).toContain("obs-capture-window"); expect(text).toContain("obs-record-clip with durationSeconds 20 and expectSilent true"); }); + + it("turns record-demo steps into a take with a chapter per step", async () => { + const prompt = await harness.mcpClient.getPrompt({ + name: "record-demo", + arguments: { window: "ChatGPT", steps: "Open settings\n\nAsk about tasks" }, + }); + const [message] = prompt.messages; + const text = message?.content.type === "text" ? message.content.text : ""; + + expect(text).toContain("obs-take-start with expectSilent true"); + expect(text).toContain(" - Open settings\n - Ask about tasks"); + expect(text).not.toContain("obs-record-clip"); + }); }); diff --git a/src/tools/take-monitor.ts b/src/tools/take-monitor.ts index f0ded75..4a5d5e9 100644 --- a/src/tools/take-monitor.ts +++ b/src/tools/take-monitor.ts @@ -122,6 +122,7 @@ export class TakeMonitor { private framesSampled = 0; private pictureChecks: "on" | "off" = "off"; private programScene: string | null = null; + private recordingPath: string | null = null; private readonly timers: NodeJS.Timeout[] = []; private readonly busy = new Set(); private releaseMeters: (() => void) | null = null; @@ -159,6 +160,7 @@ export class TakeMonitor { this.closeStill(this.now()); }); this.listen("RecordStateChanged", (data) => { + if (typeof data.outputPath === "string" && data.outputPath) this.recordingPath = data.outputPath; if (data.outputActive === false && !this.stopExpected && data.outputState === "OBS_WEBSOCKET_OUTPUT_STOPPED") { this.warn("output", "The recording stopped before it was asked to"); } @@ -186,6 +188,11 @@ export class TakeMonitor { this.changed(); } + /** The file OBS last reported for this take, from RecordStateChanged. */ + outputPath(): string | null { + return this.recordingPath; + } + /** Call before stopping the recording, so the stop is not reported as a problem. */ expectStop(): void { this.stopExpected = true; diff --git a/src/tools/takes.test.ts b/src/tools/takes.test.ts new file mode 100644 index 0000000..219085c --- /dev/null +++ b/src/tools/takes.test.ts @@ -0,0 +1,174 @@ +/* + * Modified for the independent OBS MCP project in August 2026. + * See NOTICE.md and Git history for authorship and change dates. + * SPDX-License-Identifier: GPL-2.0-only + */ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventSubscription } from "../client.js"; +import { FakeOBSRequestError } from "../../test/support/fake-obs-server.js"; +import { healthyObsState, servePreflightState, type FakeObsState } from "../../test/support/fake-obs-state.js"; +import { resultText, startMcpHarness, type HarnessOptions, type McpHarness } from "../../test/support/mcp-harness.js"; +import { currentTake } from "./takes.js"; + +let harness: McpHarness; +let recordDirectory: string; +let state: FakeObsState; +let clipPath: string; + +function sent(requestType: string): number { + return harness.fakeObs.history().filter(({ frame }) => frame.d.requestType === requestType).length; +} + +async function startHarness(options: HarnessOptions = {}): Promise { + harness = await startMcpHarness({ platform: "macos", ...options }); + servePreflightState(harness.fakeObs, () => state); + const { fakeObs } = harness; + fakeObs.respondWith("StartRecord", () => { + setTimeout(() => fakeObs.sendEvent("RecordStateChanged", { + outputActive: true, + outputState: "OBS_WEBSOCKET_OUTPUT_STARTED", + outputPath: clipPath, + }), 5); + return {}; + }); + fakeObs.respondWith("StopRecord", () => ({ outputPath: clipPath })); + fakeObs.respondWith("CreateRecordChapter", () => ({})); + fakeObs.respondWith("SetCurrentProfile", () => ({})); + fakeObs.respondWith("SetProfileParameter", () => ({})); + fakeObs.respondWith("GetStats", () => ({ availableDiskSpace: 50_000, renderSkippedFrames: 0, renderTotalFrames: 0 })); +} + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + recordDirectory = mkdtempSync(join(tmpdir(), "obs-mcp-takes-")); + clipPath = join(recordDirectory, "take.mp4"); + state = healthyObsState(recordDirectory); +}); + +afterEach(async () => { + const take = currentTake(harness.obsClient); + if (take) await harness.call("obs-take-stop", { confirm: true }); + await harness.close(); + rmSync(recordDirectory, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("recording takes", () => { + it("starts, marks, reports status, and stops with a take log", async () => { + await startHarness(); + + const start = await harness.call("obs-take-start", { expectSilent: true, chapter: "Intro" }); + expect(start.isError).toBeFalsy(); + expect(resultText(start)).toMatch(/Take \w+ is recording \(profile and output changes locked\)/); + await vi.waitFor(() => expect(harness.fakeObs.eventSubscriptions()).toBe(EventSubscription.All | EventSubscription.InputVolumeMeters)); + + const mark = await harness.call("obs-take-mark", { name: "Step 2" }); + const status = await harness.call("obs-take-status"); + const stop = await harness.call("obs-take-stop"); + + expect(resultText(mark)).toMatch(/Marked "Step 2" at [\d.]+s/); + expect(resultText(status)).toMatch(/^Take \w+: [\d.]+s/); + expect(stop.isError).toBeFalsy(); + expect(resultText(stop)).toContain(`to ${clipPath}`); + expect(resultText(stop)).toMatch(/Chapters: [\d.]+s Intro, [\d.]+s Step 2/); + expect(existsSync(join(recordDirectory, "take.take.json"))).toBe(true); + expect(sent("CreateRecordChapter")).toBe(2); + expect(currentTake(harness.obsClient)).toBeUndefined(); + await vi.waitFor(() => expect(harness.fakeObs.eventSubscriptions()).toBe(EventSubscription.All)); + }); + + it("refuses a second take while one records", async () => { + await startHarness(); + await harness.call("obs-take-start"); + + const second = await harness.call("obs-take-start"); + + expect(second.isError).toBe(true); + expect(resultText(second)).toContain("is already recording"); + expect(sent("StartRecord")).toBe(1); + }); + + it("does not start when preflight fails", async () => { + await startHarness(); + state.recordDirectory = join(recordDirectory, "missing"); + + const start = await harness.call("obs-take-start"); + + expect(start.isError).toBe(true); + expect(resultText(start)).toContain("record-directory"); + expect(sent("StartRecord")).toBe(0); + }); + + it("locks profile and output changes during a take, but never stopping", async () => { + await startHarness(); + await harness.call("obs-take-start"); + + const profile = await harness.call("obs-set-current-profile", { profileName: "Streaming" }); + const batch = await harness.call("obs-batch", { + requests: [{ requestType: "GetStats" }, { requestType: "SetProfileParameter", requestData: { parameterCategory: "Output", parameterName: "Mode", parameterValue: "Advanced" } }], + }); + const readOnly = await harness.call("obs-batch", { requests: [{ requestType: "GetStats" }] }); + + expect(resultText(profile)).toMatch(/is recording, and changing this now would switch profiles under it/); + expect(resultText(batch)).toContain("would send SetProfileParameter"); + expect(readOnly.isError).toBeFalsy(); + expect(sent("SetCurrentProfile")).toBe(0); + expect(sent("SetProfileParameter")).toBe(0); + + expect((await harness.call("obs-stop-record")).isError).toBeFalsy(); + await harness.call("obs-take-stop"); + expect((await harness.call("obs-set-current-profile", { profileName: "Streaming" })).isError).toBeFalsy(); + }); + + it("leaves changes unlocked with lock: false", async () => { + await startHarness(); + await harness.call("obs-take-start", { lock: false }); + + expect((await harness.call("obs-set-current-profile", { profileName: "Streaming" })).isError).toBeFalsy(); + }); + + it("reports the file and the unrequested stop when OBS stopped on its own", async () => { + await startHarness(); + harness.fakeObs.respondWith("StopRecord", () => { + throw new FakeOBSRequestError(501, "The output is not running"); + }); + await harness.call("obs-take-start"); + + harness.fakeObs.sendEvent("RecordStateChanged", { + outputActive: false, + outputState: "OBS_WEBSOCKET_OUTPUT_STOPPED", + outputPath: clipPath, + }); + await vi.waitFor(() => expect(currentTake(harness.obsClient)?.monitor.summary().warnings).toHaveLength(1)); + const stop = await harness.call("obs-take-stop"); + + expect(stop.isError).toBe(true); + expect(resultText(stop)).toContain(`to ${clipPath}`); + expect(resultText(stop)).toContain("The recording stopped before it was asked to"); + expect(currentTake(harness.obsClient)).toBeUndefined(); + }); + + it("answers mark, status, and stop with an error when no take is recording", async () => { + await startHarness(); + + for (const [tool, args] of [["obs-take-mark", { name: "x" }], ["obs-take-status", {}], ["obs-take-stop", {}]] as const) { + const result = await harness.call(tool, args); + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("No take is recording"); + } + }); + + it("asks before stopping a take when live confirmation is on", async () => { + await startHarness({ confirmLive: true, onElicit: () => ({ action: "decline" }) }); + await harness.call("obs-take-start"); + + const stop = await harness.call("obs-take-stop"); + + expect(resultText(stop)).toContain("Cancelled"); + expect(sent("StopRecord")).toBe(0); + expect(currentTake(harness.obsClient)).toBeDefined(); + }); +}); diff --git a/src/tools/takes.ts b/src/tools/takes.ts new file mode 100644 index 0000000..13e23ef --- /dev/null +++ b/src/tools/takes.ts @@ -0,0 +1,427 @@ +/* + * Modified for the independent OBS MCP project in August 2026. + * See NOTICE.md and Git history for authorship and change dates. + * SPDX-License-Identifier: GPL-2.0-only + */ +import crypto from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { CallToolResult, McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { OBSWebSocketClient } from "../client.js"; +import { RECORD_OUTPUT, startOutputAndConfirm } from "./output-start.js"; +import { runPreflight } from "./preflight.js"; +import { READ_ONLY_TOOL } from "./request-tool.js"; +import { describeTake, TakeMonitor, writeTakeLog, type TakeSummary } from "./take-monitor.js"; + +type JsonObject = Record; + +const execFileAsync = promisify(execFile); + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorResult(text: string): CallToolResult { + return { content: [{ type: "text", text }], isError: true }; +} + +/** JSON has no -Infinity; silence reports as null. */ +const finite = (value: number): number | null => (Number.isFinite(value) ? value : null); + +export type ClipCheck = { + durationSeconds?: number; + audioStreams?: number; + maxVolumeDb?: number; + skipped?: string; +}; + +/** + * Inspects a finished recording with ffprobe (and ffmpeg for loudness) when + * they are on PATH and OBS writes to this machine. Never throws. + */ +export async function inspectRecording(outputPath: string, measureLoudness: boolean): Promise { + let probe: JsonObject; + try { + const { stdout } = await execFileAsync("ffprobe", [ + "-v", "error", "-print_format", "json", "-show_format", "-show_streams", outputPath, + ], { timeout: 15_000 }); + probe = JSON.parse(stdout) as JsonObject; + } catch (error) { + const code = isObject(error) ? error.code : undefined; + return { skipped: code === "ENOENT" ? "ffprobe is not installed" : `ffprobe failed: ${errorMessage(error)}` }; + } + + const streams = Array.isArray(probe.streams) ? probe.streams.filter(isObject) : []; + const format = isObject(probe.format) ? probe.format : {}; + const check: ClipCheck = { + durationSeconds: Number(format.duration), + audioStreams: streams.filter((stream) => stream.codec_type === "audio").length, + }; + if (!measureLoudness || check.audioStreams === 0) return check; + + try { + const { stderr } = await execFileAsync("ffmpeg", [ + "-hide_banner", "-nostats", "-i", outputPath, "-map", "0:a:0", "-af", "volumedetect", "-f", "null", "-", + ], { timeout: 60_000 }); + const match = /max_volume:\s*(-?[\d.]+|-inf) dB/.exec(stderr); + if (match?.[1]) check.maxVolumeDb = match[1] === "-inf" ? -Infinity : Number(match[1]); + } catch (error) { + check.skipped = `ffmpeg loudness check failed: ${errorMessage(error)}`; + } + return check; +} + +/** A recording started through this server and watched until it stops. */ +export type Take = { + id: string; + monitor: TakeMonitor; + expectSilent: boolean; + /** OBS writes to this machine, so the file can be inspected. */ + local: boolean; + /** Refuse changes that break a running recording. */ + locked: boolean; + chapterNotes: string[]; +}; + +const takes = new WeakMap(); + +export function currentTake(client: OBSWebSocketClient): Take | undefined { + return takes.get(client); +} + +export type StartTakeOptions = { + expectSilent: boolean; + minFreeDiskMb: number; + lock: boolean; + chapter?: string; +}; + +/** Preflights, starts recording, waits for OBS to confirm, and starts the monitor. */ +export async function startTake( + client: OBSWebSocketClient, + options: StartTakeOptions, +): Promise<{ ok: true; take: Take } | { ok: false; result: CallToolResult }> { + const refuse = (result: CallToolResult) => ({ ok: false as const, result }); + const running = currentTake(client); + if (running) return refuse(errorResult(`Take ${running.id} is already recording; stop it with obs-take-stop first`)); + + const preflight = await runPreflight(client, { minFreeDiskMb: options.minFreeDiskMb, expectSilent: options.expectSilent }); + if (!preflight.ready) { + const failures = preflight.checks.filter(({ status }) => status === "fail"); + return refuse(errorResult( + `Not recording; preflight failed:\n${failures.map(({ id, message }) => `- ${id}: ${message}`).join("\n")}`, + )); + } + + const started = await startOutputAndConfirm(client, RECORD_OUTPUT); + if (started.isError) return refuse(started); + + const take: Take = { + id: crypto.randomUUID().slice(0, 8), + monitor: new TakeMonitor(client, { expectSilent: options.expectSilent }), + expectSilent: options.expectSilent, + local: preflight.checks.some(({ id, status }) => id === "record-directory" && status === "pass"), + locked: options.lock, + chapterNotes: [], + }; + takes.set(client, take); + await take.monitor.start(); + if (options.chapter) await markTake(client, take, options.chapter); + return { ok: true, take }; +} + +/** Adds a chapter to the file and the take log. A rejected chapter does not stop the take. */ +export async function markTake(client: OBSWebSocketClient, take: Take, name: string): Promise<{ atSeconds: number; note?: string }> { + const atSeconds = take.monitor.now(); + take.monitor.mark(name); + try { + await client.sendRequest("CreateRecordChapter", { chapterName: name }); + return { atSeconds }; + } catch (error) { + // Chapters need the Hybrid MP4 or Hybrid MOV format. + const note = `Chapter "${name}" not added: ${errorMessage(error)}`; + take.chapterNotes.push(note); + return { atSeconds, note }; + } +} + +/** + * Stops the recording, stops the monitor, checks the file, and writes the + * take log. Never throws, so callers can stop from a finally block. + */ +export async function stopTake( + client: OBSWebSocketClient, + take: Take, + options: { requestedSeconds?: number } = {}, +): Promise { + take.monitor.expectStop(); + let outputPath: string | undefined; + let stopError: string | undefined; + try { + const stopped: unknown = await client.sendRequest("StopRecord"); + if (isObject(stopped) && typeof stopped.outputPath === "string") outputPath = stopped.outputPath; + } catch (error) { + // The recording may have stopped already; OBS reported the file when it did. + stopError = errorMessage(error); + } + const summary = await take.monitor.stop(); + if (takes.get(client) === take) takes.delete(client); + outputPath ??= take.monitor.outputPath() ?? undefined; + + if (!outputPath) { + return errorResult( + `Take ${take.id} ran ${summary.durationSeconds}s, but OBS did not confirm the stop or report the file` + + `${stopError ? ` (${stopError})` : ""}. Check obs-get-record-status`, + ); + } + return takeResult(take, summary, outputPath, options.requestedSeconds); +} + +async function takeResult( + take: Take, + summary: TakeSummary, + outputPath: string, + requestedSeconds: number | undefined, +): Promise { + const inspection = take.local + ? await inspectRecording(outputPath, take.expectSilent) + : { skipped: "OBS is remote, so the file was not inspected" }; + const takeLog = take.local ? await writeTakeLog(outputPath, summary) : null; + + const problems: string[] = []; + for (const warning of summary.warnings) { + if (warning.kind === "blank" || warning.kind === "output" || (warning.kind === "audio" && take.expectSilent)) { + problems.push(`At ${warning.atSeconds}s: ${warning.message}`); + } + } + const expectedSeconds = requestedSeconds ?? summary.durationSeconds; + if (inspection.durationSeconds !== undefined && inspection.durationSeconds < expectedSeconds - 2) { + problems.push(requestedSeconds !== undefined + ? `The file is ${inspection.durationSeconds.toFixed(1)}s, shorter than the requested ${requestedSeconds}s` + : `The file is ${inspection.durationSeconds.toFixed(1)}s, but the take ran ${summary.durationSeconds}s`); + } + if (take.expectSilent && inspection.maxVolumeDb !== undefined && inspection.maxVolumeDb > -60) { + problems.push(`Expected silence, but the audio peaks at ${inspection.maxVolumeDb} dB`); + } + + const lines = [ + `Recorded ${requestedSeconds ?? summary.durationSeconds}s to ${outputPath}`, + inspection.skipped + ? `Not verified: ${inspection.skipped}` + : `Verified: ${inspection.durationSeconds?.toFixed(1)}s, ${inspection.audioStreams} audio stream(s)` + + (inspection.maxVolumeDb !== undefined ? `, peak ${inspection.maxVolumeDb} dB` : ""), + ...describeTake(summary).filter((line) => !line.startsWith("Warning")), + ...(summary.chapters.length > 0 + ? [`Chapters: ${summary.chapters.map(({ atSeconds, name }) => `${atSeconds}s ${name}`).join(", ")}`] + : []), + ...(takeLog ? [`Take log: ${takeLog}`] : []), + ...take.chapterNotes, + ...problems.map((problem) => `Problem: ${problem}`), + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { + takeId: take.id, + outputPath, + ...inspection, + ...(inspection.maxVolumeDb === -Infinity ? { maxVolumeDb: null } : {}), + chapterNotes: take.chapterNotes, + problems, + takeLog, + take: { + ...summary, + audio: summary.audio.map((level) => ({ ...level, peakDb: finite(level.peakDb), inputPeakDb: finite(level.inputPeakDb) })), + }, + }, + ...(problems.length > 0 ? { isError: true } : {}), + }; +} + +/** Tools that change what a running recording writes, or where. Stopping is never locked. */ +export const LOCKED_TOOLS: Readonly> = { + "obs-set-current-profile": "switch profiles", + "obs-create-profile": "create and switch to a profile", + "obs-remove-profile": "remove a profile", + "obs-set-current-scene-collection": "switch scene collections", + "obs-create-scene-collection": "create and switch to a scene collection", + "obs-set-profile-parameter": "change profile settings", + "obs-set-record-directory": "change the recording directory", + "obs-set-video-settings": "change the canvas and frame rate", + "obs-set-stream-service-settings": "change the stream service", +}; + +const LOCKED_REQUESTS = new Set([ + "SetCurrentProfile", + "CreateProfile", + "RemoveProfile", + "SetCurrentSceneCollection", + "CreateSceneCollection", + "SetProfileParameter", + "SetRecordDirectory", + "SetVideoSettings", + "SetStreamServiceSettings", +]); + +function lockedAction(name: string, args: unknown): string | undefined { + const fixed = LOCKED_TOOLS[name]; + if (fixed) return fixed; + if (!isObject(args)) return undefined; + const requestTypes = name === "obs-call-request" + ? [args.requestType] + : name === "obs-batch" && Array.isArray(args.requests) + ? args.requests.map((request) => (isObject(request) ? request.requestType : undefined)) + : []; + const locked = requestTypes.filter((type): type is string => typeof type === "string" && LOCKED_REQUESTS.has(type)); + return locked.length > 0 ? `send ${locked.join(", ")}` : undefined; +} + +/** + * Wraps tool registration so that, while a locked take records, tools that + * would change the profile, scene collection, or output settings refuse to + * run. Other McpServer methods stay bound to the real server. + */ +export function withTakeLock(server: McpServer, client: OBSWebSocketClient): McpServer { + return new Proxy(server, { + get(target, property) { + if (property === "registerTool") { + const registerTool = Reflect.get(target, property, target) as (...args: unknown[]) => unknown; + return (name: string, config: unknown, callback: (...args: unknown[]) => unknown) => { + const guarded = async (...callbackArgs: unknown[]) => { + const take = currentTake(client); + // Tools without an input schema receive only the context. + const action = take?.locked ? lockedAction(name, callbackArgs.length > 1 ? callbackArgs[0] : undefined) : undefined; + if (take && action) { + return errorResult( + `Take ${take.id} is recording, and changing this now would ${action} under it. ` + + "Stop the take with obs-take-stop first, or start takes with lock: false", + ); + } + return callback(...callbackArgs); + }; + return Reflect.apply(registerTool, target, [name, config, guarded]); + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +function noTake(): CallToolResult { + return errorResult("No take is recording; start one with obs-take-start"); +} + +export function initialize(server: McpServer, client: OBSWebSocketClient): void { + server.registerTool( + "obs-take-start", + { + title: "Start Take", + description: "Start a watched recording of any length: runs obs-preflight, starts recording and waits for " + + "OBS to confirm, then watches audio, frames, and picture until obs-take-stop. Mark steps with " + + "obs-take-mark and check progress with obs-take-status. By default, profile, scene collection, and " + + "output setting changes are refused until the take stops", + inputSchema: z.object({ + expectSilent: z.boolean().default(false) + .describe("Refuse to start with audible inputs, and treat audio during the take as a problem"), + minFreeDiskMb: z.number().int().nonnegative().default(1024).describe("Minimum free disk space in MB"), + lock: z.boolean().default(true).describe("Refuse profile, scene collection, and output setting changes until the take stops"), + chapter: z.string().min(1).optional().describe("Name of a chapter to add at the start"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async (args) => { + try { + const started = await startTake(client, args); + if (!started.ok) return started.result; + const { take } = started; + const status = take.monitor.summary(); + return { + content: [{ + type: "text", + text: `Take ${take.id} is recording${take.locked ? " (profile and output changes locked)" : ""}. ` + + `Watching audio${status.pictureChecks === "on" ? ", frames, and picture" : " and frames"}; ` + + "stop it with obs-take-stop", + }], + structuredContent: { takeId: take.id, locked: take.locked, pictureChecks: status.pictureChecks, chapterNotes: take.chapterNotes }, + }; + } catch (error) { + return errorResult(`Start Take failed: ${errorMessage(error)}`); + } + }, + ); + + server.registerTool( + "obs-take-mark", + { + title: "Mark Take", + description: "Add a named chapter to the running take, in the file (Hybrid MP4/MOV) and in the take log", + inputSchema: z.object({ name: z.string().min(1).describe("Chapter name") }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ name }) => { + const take = currentTake(client); + if (!take) return noTake(); + const { atSeconds, note } = await markTake(client, take, name); + return { + content: [{ type: "text", text: note ?? `Marked "${name}" at ${atSeconds}s` }], + structuredContent: { takeId: take.id, atSeconds, name, ...(note ? { note } : {}) }, + }; + }, + ); + + server.registerTool( + "obs-take-status", + { + title: "Take Status", + description: "Report the running take: length, file size, loudest recorded input, skipped frames, black or " + + "unchanging picture, chapters, and warnings so far", + inputSchema: z.object({}), + annotations: READ_ONLY_TOOL, + }, + async () => { + const take = currentTake(client); + if (!take) return noTake(); + const summary = take.monitor.summary(); + const record = await client.sendRequest("GetRecordStatus").catch(() => ({})) as JsonObject; + const lines = [ + `Take ${take.id}: ${summary.durationSeconds}s` + + (typeof record.outputBytes === "number" ? `, ${(record.outputBytes / 1_048_576).toFixed(1)} MB` : "") + + (record.outputPaused === true ? ", paused" : ""), + ...describeTake(summary), + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { + takeId: take.id, + record, + take: { + ...summary, + audio: summary.audio.map((level) => ({ ...level, peakDb: finite(level.peakDb), inputPeakDb: finite(level.inputPeakDb) })), + }, + }, + }; + }, + ); + + server.registerTool( + "obs-take-stop", + { + title: "Stop Take", + description: "Stop the running take: stops recording, then checks the file's length and audio with " + + "ffprobe/ffmpeg when available, reports what the monitor saw, and writes .take.json next to a " + + "local recording", + inputSchema: z.object({}), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, + async () => { + const take = currentTake(client); + if (!take) return noTake(); + return stopTake(client, take); + }, + ); +} diff --git a/src/tools/tool-inventory.test.ts b/src/tools/tool-inventory.test.ts index 469ea10..c0c0b9a 100644 --- a/src/tools/tool-inventory.test.ts +++ b/src/tools/tool-inventory.test.ts @@ -13,7 +13,7 @@ import { initialize } from "./index.js"; // docs/protocol.json) cannot silently rename tools or change their arguments. // Tools that end a broadcast, finalize a recording, or cut off consumers of an // output must be marked destructive so clients can ask before calling them. -const DESTRUCTIVE_NAME = /^obs-(remove-.+|stop-(stream|record|output|virtual-cam|replay-buffer)|toggle-(stream|record|output|virtual-cam|replay-buffer))$/; +const DESTRUCTIVE_NAME = /^obs-(remove-.+|take-stop|stop-(stream|record|output|virtual-cam|replay-buffer)|toggle-(stream|record|output|virtual-cam|replay-buffer))$/; describe("tool contract snapshot", () => { it("keeps tool names, input and output schemas, and annotations stable", async () => { @@ -33,7 +33,7 @@ describe("tool contract snapshot", () => { .map(({ name }) => name); expect(unsafe).toEqual([]); const readOnlyMutators = contract - .filter(({ name, annotations }) => annotations?.readOnlyHint && !/^obs-(get|list|test|describe)-|^obs-(sleep|preflight)$/.test(name)) + .filter(({ name, annotations }) => annotations?.readOnlyHint && !/^obs-(get|list|test|describe)-|^obs-(sleep|preflight|take-status)$/.test(name)) .map(({ name }) => name); expect(readOnlyMutators).toEqual([]); await expect(`${JSON.stringify(contract, null, 2)}\n`) diff --git a/src/tools/workflows.ts b/src/tools/workflows.ts index 616ce6e..2206e7b 100644 --- a/src/tools/workflows.ts +++ b/src/tools/workflows.ts @@ -3,20 +3,14 @@ * See NOTICE.md and Git history for authorship and change dates. * SPDX-License-Identifier: GPL-2.0-only */ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import type { CallToolResult, McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import { RequestBatchExecutionType, type OBSWebSocketClient } from "../client.js"; import { appendInputScreenshot, inputStateResult, readInputState } from "./after-change.js"; -import { RECORD_OUTPUT, startOutputAndConfirm } from "./output-start.js"; -import { runPreflight } from "./preflight.js"; -import { describeTake, TakeMonitor, writeTakeLog, type TakeSummary } from "./take-monitor.js"; +import { markTake, startTake, stopTake } from "./takes.js"; type JsonObject = Record; -const execFileAsync = promisify(execFile); - /** Many MCP clients time a tool call out at 60 seconds. */ export const MAX_CLIP_SECONDS = 50; const SCK_WINDOW_CAPTURE = 1; @@ -35,140 +29,30 @@ function errorResult(text: string): CallToolResult { return { content: [{ type: "text", text }], isError: true }; } -/** JSON has no -Infinity; silence reports as null. */ -const finite = (value: number): number | null => (Number.isFinite(value) ? value : null); - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -export type ClipCheck = { - durationSeconds?: number; - audioStreams?: number; - maxVolumeDb?: number; - skipped?: string; -}; - -/** - * Inspects a finished recording with ffprobe (and ffmpeg for loudness) when - * they are on PATH and OBS writes to this machine. Never throws. - */ -export async function inspectRecording(outputPath: string, measureLoudness: boolean): Promise { - let probe: JsonObject; - try { - const { stdout } = await execFileAsync("ffprobe", [ - "-v", "error", "-print_format", "json", "-show_format", "-show_streams", outputPath, - ], { timeout: 15_000 }); - probe = JSON.parse(stdout) as JsonObject; - } catch (error) { - const code = isObject(error) ? error.code : undefined; - return { skipped: code === "ENOENT" ? "ffprobe is not installed" : `ffprobe failed: ${errorMessage(error)}` }; - } - - const streams = Array.isArray(probe.streams) ? probe.streams.filter(isObject) : []; - const format = isObject(probe.format) ? probe.format : {}; - const check: ClipCheck = { - durationSeconds: Number(format.duration), - audioStreams: streams.filter((stream) => stream.codec_type === "audio").length, - }; - if (!measureLoudness || check.audioStreams === 0) return check; - - try { - const { stderr } = await execFileAsync("ffmpeg", [ - "-hide_banner", "-nostats", "-i", outputPath, "-map", "0:a:0", "-af", "volumedetect", "-f", "null", "-", - ], { timeout: 60_000 }); - const match = /max_volume:\s*(-?[\d.]+|-inf) dB/.exec(stderr); - if (match?.[1]) check.maxVolumeDb = match[1] === "-inf" ? -Infinity : Number(match[1]); - } catch (error) { - check.skipped = `ffmpeg loudness check failed: ${errorMessage(error)}`; - } - return check; -} - async function recordClip( client: OBSWebSocketClient, args: { durationSeconds: number; chapters: { atSeconds: number; name: string }[]; expectSilent: boolean }, ): Promise { - const preflight = await runPreflight(client, { minFreeDiskMb: 1024, expectSilent: args.expectSilent }); - if (!preflight.ready) { - const failures = preflight.checks.filter(({ status }) => status === "fail"); - return errorResult( - `Not recording; preflight failed:\n${failures.map(({ id, message }) => `- ${id}: ${message}`).join("\n")}`, - ); - } - - const started = await startOutputAndConfirm(client, RECORD_OUTPUT); - if (started.isError) return started; + const started = await startTake(client, { expectSilent: args.expectSilent, minFreeDiskMb: 1024, lock: true }); + if (!started.ok) return started.result; + const { take } = started; const startedAt = Date.now(); - const monitor = new TakeMonitor(client, { expectSilent: args.expectSilent }); - const chapterNotes: string[] = []; - let outputPath: string | undefined; - let take: TakeSummary; + let result: CallToolResult | undefined; try { - await monitor.start(); const chapters = [...args.chapters].sort((a, b) => a.atSeconds - b.atSeconds); for (const chapter of chapters) { await sleep(Math.max(0, startedAt + chapter.atSeconds * 1000 - Date.now())); - monitor.mark(chapter.name); - try { - await client.sendRequest("CreateRecordChapter", { chapterName: chapter.name }); - } catch (error) { - // Chapters need Hybrid MP4/MOV; a missing chapter must not abort the take. - chapterNotes.push(`Chapter "${chapter.name}" not added: ${errorMessage(error)}`); - } + await markTake(client, take, chapter.name); } await sleep(Math.max(0, startedAt + args.durationSeconds * 1000 - Date.now())); } finally { - monitor.expectStop(); - const stopped: unknown = await client.sendRequest("StopRecord").catch(() => undefined); - if (isObject(stopped) && typeof stopped.outputPath === "string") outputPath = stopped.outputPath; - take = await monitor.stop(); - } - - if (!outputPath) { - return errorResult("Recording started, but OBS did not confirm the stop or report the file. Check obs-get-record-status"); + // stopTake never throws, so the recording always stops. + result = await stopTake(client, take, { requestedSeconds: args.durationSeconds }); } - - const local = preflight.checks.some(({ id, status }) => id === "record-directory" && status === "pass"); - const inspection = local - ? await inspectRecording(outputPath, args.expectSilent) - : { skipped: "OBS is remote, so the file was not inspected" }; - - const takeLog = local ? await writeTakeLog(outputPath, take) : null; - const problems: string[] = []; - for (const warning of take.warnings.filter(({ kind }) => kind === "blank" || kind === "output" || (kind === "audio" && args.expectSilent))) { - problems.push(`At ${warning.atSeconds}s: ${warning.message}`); - } - if (inspection.durationSeconds !== undefined && inspection.durationSeconds < args.durationSeconds - 2) { - problems.push(`The file is ${inspection.durationSeconds.toFixed(1)}s, shorter than the requested ${args.durationSeconds}s`); - } - if (args.expectSilent && inspection.maxVolumeDb !== undefined && inspection.maxVolumeDb > -60) { - problems.push(`Expected silence, but the audio peaks at ${inspection.maxVolumeDb} dB`); - } - - const lines = [ - `Recorded ${args.durationSeconds}s to ${outputPath}`, - inspection.skipped - ? `Not verified: ${inspection.skipped}` - : `Verified: ${inspection.durationSeconds?.toFixed(1)}s, ${inspection.audioStreams} audio stream(s)` - + (inspection.maxVolumeDb !== undefined ? `, peak ${inspection.maxVolumeDb} dB` : ""), - ...describeTake(take).filter((line) => !line.startsWith("Warning")), - ...(takeLog ? [`Take log: ${takeLog}`] : []), - ...chapterNotes, - ...problems.map((problem) => `Problem: ${problem}`), - ]; - return { - content: [{ type: "text", text: lines.join("\n") }], - structuredContent: { - outputPath, - ...inspection, - ...(inspection.maxVolumeDb === -Infinity ? { maxVolumeDb: null } : {}), - chapterNotes, - problems, - takeLog, - take: { ...take, audio: take.audio.map((level) => ({ ...level, peakDb: finite(level.peakDb), inputPeakDb: finite(level.inputPeakDb) })) }, - }, - ...(problems.length > 0 ? { isError: true } : {}), - }; + return result; } type ListItem = { itemName: string; itemValue: unknown; itemEnabled?: boolean }; diff --git a/test/support/tool-count.ts b/test/support/tool-count.ts index 2f2ef93..bfce1f1 100644 --- a/test/support/tool-count.ts +++ b/test/support/tool-count.ts @@ -5,4 +5,4 @@ */ /** Number of tools the server registers with no tool filtering configured. */ -export const TOOL_COUNT = 159; +export const TOOL_COUNT = 163;