diff --git a/README.md b/README.md index deb4db1..5258edf 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Open that file in an MCPB-compatible desktop client. The package prompts for the | `OBS_MCP_TOOLSETS` | No | all tools | Comma-separated tool groups to register: `all`, `core`, or any group listed under [Choosing tools](#choosing-tools) | | `OBS_MCP_TOOLS` | No | none | Comma-separated tool names to register in addition to `OBS_MCP_TOOLSETS` | | `OBS_MCP_READ_ONLY` | No | `false` | Register only read-only tools; overrides both lists | +| `OBS_MCP_DYNAMIC_TOOLSETS` | No | `false` | Start with only the `OBS_MCP_TOOLSETS` groups (default `general`) and let the model turn others on and off (see [Choosing tools](#choosing-tools)) | | `OBS_MCP_CONFIRM_LIVE` | No | `false` | Ask before stopping or toggling a stream or recording (see [Confirming live actions](#confirming-live-actions)) | | `OBS_MCP_LOG_LEVEL` | No | `info` | Diagnostic output on stderr: `debug`, `info`, `error`, or `silent` | @@ -151,6 +152,8 @@ Every tool is registered by default. Clients load each registered tool's definit `core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (90 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 41-tool inspection-only server. The server refuses to start when a group or tool name is unknown. +With `OBS_MCP_DYNAMIC_TOOLSETS=true`, every group is registered but only the groups in `OBS_MCP_TOOLSETS` (by default just `general`) and the tools in `OBS_MCP_TOOLS` start enabled. Three more tools manage the rest: `obs-list-toolsets` shows each group and how many of its tools are on, and `obs-enable-toolset` and `obs-disable-toolset` switch groups for the session. Each change sends `notifications/tools/list_changed`, so clients that support it (Claude Code, VS Code, Cursor) reload the list. Read-only mode still applies: enabling a group adds only its read-only tools. + ### Resources and prompts Resources give clients that support them a live view without tool calls. Each one duplicates a read-only tool, since many clients read resources rarely or not at all. diff --git a/manifest.json b/manifest.json index 16685e9..e1f9c69 100644 --- a/manifest.json +++ b/manifest.json @@ -51,7 +51,8 @@ "OBS_MCP_TOOLSETS": "${user_config.obs_mcp_toolsets}", "OBS_MCP_READ_ONLY": "${user_config.obs_mcp_read_only}", "OBS_MCP_CONFIRM_LIVE": "${user_config.obs_mcp_confirm_live}", - "OBS_MCP_READ_OBS_CONFIG": "${user_config.obs_mcp_read_obs_config}" + "OBS_MCP_READ_OBS_CONFIG": "${user_config.obs_mcp_read_obs_config}", + "OBS_MCP_DYNAMIC_TOOLSETS": "${user_config.obs_mcp_dynamic_toolsets}" } } }, @@ -91,6 +92,13 @@ "required": false, "default": false }, + "obs_mcp_dynamic_toolsets": { + "type": "boolean", + "title": "Load tool groups on demand", + "description": "Start with only the tool groups above (or just general) and let the model turn others on as it needs them.", + "required": false, + "default": false + }, "obs_mcp_read_obs_config": { "type": "boolean", "title": "Use the password saved in OBS", diff --git a/src/server.ts b/src/server.ts index 0e347d2..6fa0245 100644 --- a/src/server.ts +++ b/src/server.ts @@ -116,7 +116,10 @@ function loadToolFilter(): ToolFilter { assertKnownTools(filter, registry); const count = registry.filter(({ registered }) => registered).length; if (count === 0) throw new Error("The tool filter excludes every tool"); - if (filter.groups !== null || filter.readOnly) { + if (filter.dynamic) { + const enabled = registry.filter(({ handle }) => handle?.enabled).length; + logger.log(`Dynamic tool groups: ${enabled} of ${count} tools enabled at the start${filter.readOnly ? " (read-only)" : ""}`); + } else if (filter.groups !== null || filter.readOnly) { logger.log(`Registering ${count} of ${registry.length} tools${filter.readOnly ? " (read-only)" : ""}`); } return filter; diff --git a/src/tools/index.ts b/src/tools/index.ts index a640656..ac85d5a 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -28,6 +28,7 @@ import * as takes from "./takes.js"; import * as trim from "./trim.js"; import * as snapshots from "./snapshots.js"; import * as applyScene from "./apply-scene.js"; +import * as toolsetTools from "./toolset-tools.js"; import * as resources from "./resources.js"; import * as prompts from "./prompts.js"; import { withStructuredToolResults } from "./results.js"; @@ -84,6 +85,7 @@ export function initialize( for (const [group, module] of MODULES) { module.initialize(scopedServer(confirmingServer, group, filter, registry), client); } + if (filter.dynamic) toolsetTools.initialize(structuredServer, registry); if (resourcesAndPrompts) { resources.initialize(server, client); prompts.initialize(server); diff --git a/src/tools/toolset-tools.test.ts b/src/tools/toolset-tools.test.ts new file mode 100644 index 0000000..7f17061 --- /dev/null +++ b/src/tools/toolset-tools.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resultText, startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js"; +import { parseToolFilter } from "./toolsets.js"; + +let harness: McpHarness | undefined; +let listChanges = 0; + +async function start(env: Record): Promise { + harness = await startMcpHarness({ filter: parseToolFilter(env) }); + harness.mcpClient.setNotificationHandler("notifications/tools/list_changed", async () => { + listChanges += 1; + }); + harness.fakeObs.respondWith("GetRecordStatus", () => ({ outputActive: false })); + return harness; +} + +async function toolNames(): Promise { + return (await harness!.mcpClient.listTools()).tools.map(({ name }) => name); +} + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + listChanges = 0; +}); + +afterEach(async () => { + await harness?.close(); + harness = undefined; + vi.restoreAllMocks(); +}); + +describe("dynamic tool groups", () => { + it("parses OBS_MCP_DYNAMIC_TOOLSETS, starting with general unless groups are named", () => { + expect(parseToolFilter({ OBS_MCP_DYNAMIC_TOOLSETS: "true" })).toMatchObject({ dynamic: true, groups: new Set(["general"]) }); + expect(parseToolFilter({ OBS_MCP_DYNAMIC_TOOLSETS: "1", OBS_MCP_TOOLSETS: "record" }).groups).toEqual(new Set(["record"])); + expect(parseToolFilter({}).dynamic).toBe(false); + }); + + it("starts small, then enables and disables groups with a list-changed notification each time", async () => { + await start({ OBS_MCP_DYNAMIC_TOOLSETS: "true" }); + const initial = await toolNames(); + expect(initial).toEqual(expect.arrayContaining(["obs-get-status", "obs-list-toolsets", "obs-enable-toolset", "obs-disable-toolset"])); + expect(initial).not.toContain("obs-get-record-status"); + expect(initial.length).toBeLessThan(30); + + const list = await harness!.call("obs-list-toolsets"); + expect(resultText(list)).toMatch(/^on general \(\d+\/\d+\)/m); + expect(resultText(list)).toMatch(/^off record \(0\/\d+\)/m); + + const enabled = await harness!.call("obs-enable-toolset", { groups: ["record"] }); + expect(resultText(enabled)).toMatch(/Enabled \d+ tool\(s\) in record/); + await vi.waitFor(() => expect(listChanges).toBeGreaterThan(0)); + expect(await toolNames()).toContain("obs-get-record-status"); + expect((await harness!.call("obs-get-record-status")).isError).toBeFalsy(); + + await harness!.call("obs-disable-toolset", { groups: ["record"] }); + expect(await toolNames()).not.toContain("obs-get-record-status"); + await expect(harness!.call("obs-get-record-status")).rejects.toThrow("disabled"); + expect(resultText(await harness!.call("obs-disable-toolset", { groups: ["record"] }))).toContain("already off"); + }); + + it("enables only read-only tools in read-only mode", async () => { + await start({ OBS_MCP_DYNAMIC_TOOLSETS: "true", OBS_MCP_READ_ONLY: "true" }); + + await harness!.call("obs-enable-toolset", { groups: ["record"] }); + + const names = await toolNames(); + expect(names).toContain("obs-get-record-status"); + expect(names).not.toContain("obs-start-record"); + }); + + it("adds no group tools without OBS_MCP_DYNAMIC_TOOLSETS", async () => { + await start({}); + + expect(await toolNames()).not.toContain("obs-enable-toolset"); + }); +}); diff --git a/src/tools/toolset-tools.ts b/src/tools/toolset-tools.ts new file mode 100644 index 0000000..7975e59 --- /dev/null +++ b/src/tools/toolset-tools.ts @@ -0,0 +1,104 @@ +/* + * 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 type { CallToolResult, McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { READ_ONLY_TOOL } from "./request-tool.js"; +import { TOOL_GROUPS, type RegisteredTool, type ToolGroup } from "./toolsets.js"; + +const GROUP_DESCRIPTIONS: Record = { + general: "status, version, statistics, hotkeys, vendor requests, custom events, sleep", + scenes: "scenes, canvases, program and preview scene, obs-apply-scene", + "scene-items": "scene items, groups, transforms, ordering, locking, blend modes, snapshots and restore", + sources: "source screenshots and active state", + inputs: "inputs, input settings and properties, audio, deinterlacing, obs-capture-window", + media: "media input playback", + filters: "source filters", + transitions: "transitions, overrides, the T-Bar", + record: "recording, chapters, preflight, clips, watched takes, trimming, contact sheets", + 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: "describing and calling any OBS request, request batches", +}; + +const GroupList = z.array(z.enum(TOOL_GROUPS)).min(1); + +function summary(registry: readonly RegisteredTool[]) { + return TOOL_GROUPS.map((group) => { + const tools = registry.filter((tool) => tool.group === group && tool.handle); + const enabled = tools.filter(({ handle }) => handle!.enabled).length; + return { group, enabled, total: tools.length, covers: GROUP_DESCRIPTIONS[group] }; + }).filter(({ total }) => total > 0); +} + +function setGroups(registry: readonly RegisteredTool[], groups: readonly ToolGroup[], enabled: boolean): string[] { + const changed: string[] = []; + for (const tool of registry) { + if (!tool.handle || !groups.includes(tool.group) || tool.handle.enabled === enabled) continue; + if (enabled) tool.handle.enable(); + else tool.handle.disable(); + changed.push(tool.name); + } + return changed; +} + +/** + * With OBS_MCP_DYNAMIC_TOOLSETS, the session starts with a few groups and + * these tools turn the others on and off. Each change sends + * notifications/tools/list_changed, so the client reloads the tool list. + */ +export function initialize(server: McpServer, registry: readonly RegisteredTool[]): void { + server.registerTool( + "obs-list-toolsets", + { + title: "List Tool Groups", + description: "List the groups of OBS tools, what each covers, and how many of its tools are enabled. " + + "Enable a group with obs-enable-toolset before using its tools", + inputSchema: z.object({}), + annotations: READ_ONLY_TOOL, + }, + async (): Promise => { + const groups = summary(registry); + return { + content: [{ + type: "text", + text: groups.map(({ group, enabled, total, covers }) => ( + `${enabled === total ? "on " : enabled === 0 ? "off" : "part"} ${group} (${enabled}/${total}): ${covers}` + )).join("\n"), + }], + structuredContent: { groups }, + }; + }, + ); + + const register = (enabled: boolean) => server.registerTool( + enabled ? "obs-enable-toolset" : "obs-disable-toolset", + { + title: enabled ? "Enable Tool Groups" : "Disable Tool Groups", + description: enabled + ? "Turn on groups of OBS tools for this session; the client reloads its tool list. See obs-list-toolsets" + : "Turn off groups of OBS tools for this session to keep the tool list short", + inputSchema: z.object({ groups: GroupList.describe("Groups from obs-list-toolsets") }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ groups }): Promise => { + const changed = setGroups(registry, groups, enabled); + const verb = enabled ? "Enabled" : "Disabled"; + return { + content: [{ + type: "text", + text: changed.length > 0 + ? `${verb} ${changed.length} tool(s) in ${groups.join(", ")}: ${changed.join(", ")}` + : `Nothing to change: ${groups.join(", ")} ${enabled ? "already on" : "already off"}`, + }], + structuredContent: { groups, changed }, + }; + }, + ); + register(true); + register(false); +} diff --git a/src/tools/toolsets.test.ts b/src/tools/toolsets.test.ts index 852bfdc..ee90f47 100644 --- a/src/tools/toolsets.test.ts +++ b/src/tools/toolsets.test.ts @@ -24,7 +24,7 @@ function registeredNames(filter: ToolFilter): string[] { describe("parseToolFilter", () => { it("registers every tool when nothing is configured", () => { - expect(parseToolFilter({})).toEqual({ groups: null, tools: new Set(), readOnly: false }); + expect(parseToolFilter({})).toEqual({ groups: null, tools: new Set(), readOnly: false, dynamic: false }); expect(parseToolFilter({ OBS_MCP_TOOLSETS: "all,scenes" }).groups).toBeNull(); }); diff --git a/src/tools/toolsets.ts b/src/tools/toolsets.ts index af6b462..2eee6d9 100644 --- a/src/tools/toolsets.ts +++ b/src/tools/toolsets.ts @@ -34,20 +34,30 @@ export const CORE_GROUPS: readonly ToolGroup[] = [ "record", ]; +/** Groups enabled at the start with OBS_MCP_DYNAMIC_TOOLSETS and no OBS_MCP_TOOLSETS. */ +export const DYNAMIC_START_GROUPS: readonly ToolGroup[] = ["general"]; + export type ToolFilter = { - /** null registers every group. */ + /** null registers every group. With dynamic, the groups enabled at the start. */ groups: ReadonlySet | null; tools: ReadonlySet; readOnly: boolean; + /** Register every group but enable only `groups` and `tools`; the model enables more at runtime. */ + dynamic?: boolean; }; export const ALL_TOOLS: ToolFilter = { groups: null, tools: new Set(), readOnly: false }; +/** Enables or disables a registered tool; the SDK tells connected clients the list changed. */ +export type ToolHandle = { enabled: boolean; enable(): void; disable(): void }; + export type RegisteredTool = { name: string; group: ToolGroup; annotations: ToolAnnotations | undefined; registered: boolean; + /** Present for registered tools; with dynamic toolsets, disabled ones can be enabled later. */ + handle?: ToolHandle; }; function splitList(value: string | undefined): string[] { @@ -67,9 +77,13 @@ export function parseToolFilter(env: NodeJS.ProcessEnv = process.env): ToolFilte const groupNames = splitList(env.OBS_MCP_TOOLSETS); const tools = new Set(splitList(env.OBS_MCP_TOOLS)); const readOnly = ["1", "true", "yes"].includes((env.OBS_MCP_READ_ONLY ?? "").trim().toLowerCase()); + const dynamic = ["1", "true", "yes"].includes((env.OBS_MCP_DYNAMIC_TOOLSETS ?? "").trim().toLowerCase()); - if (groupNames.length === 0 && tools.size === 0) return { groups: null, tools, readOnly }; - if (groupNames.includes("all")) return { groups: null, tools, readOnly }; + if (dynamic && groupNames.length === 0) { + return { groups: new Set(DYNAMIC_START_GROUPS), tools, readOnly, dynamic }; + } + if (groupNames.length === 0 && tools.size === 0) return { groups: null, tools, readOnly, dynamic }; + if (groupNames.includes("all")) return { groups: null, tools, readOnly, dynamic }; const groups = new Set(); for (const name of groupNames) { @@ -83,10 +97,17 @@ export function parseToolFilter(env: NodeJS.ProcessEnv = process.env): ToolFilte ); } } - return { groups, tools, readOnly }; + return { groups, tools, readOnly, dynamic }; +} + +/** Whether a tool is registered: with dynamic toolsets, every tool read-only mode allows. */ +export function registersTool(filter: ToolFilter, tool: Pick): boolean { + if (filter.readOnly && tool.annotations?.readOnlyHint !== true) return false; + return filter.dynamic ? true : includesTool(filter, tool); } -export function includesTool(filter: ToolFilter, tool: Omit): boolean { +/** Whether a tool is registered, or with dynamic toolsets, enabled at the start. */ +export function includesTool(filter: ToolFilter, tool: Pick): boolean { // Read-only mode wins over explicit groups and tool names. if (filter.readOnly && tool.annotations?.readOnlyHint !== true) return false; if (filter.groups === null) return true; @@ -155,10 +176,16 @@ export function scopedServer( if (typeof name !== "string") throw new TypeError("MCP tool registration requires a name"); const annotations = (config as { annotations?: ToolAnnotations } | undefined)?.annotations; const tool = { name, group: GROUP_OVERRIDES[name] ?? group, annotations }; - const registered = includesTool(filter, tool); - registry.push({ ...tool, registered }); - if (!registered) return undefined; - return Reflect.apply(Reflect.get(target, property, target) as Function, target, registrationArgs); + const registered = registersTool(filter, tool); + if (!registered) { + registry.push({ ...tool, registered }); + return undefined; + } + const handle = Reflect.apply(Reflect.get(target, property, target) as Function, target, registrationArgs) as ToolHandle; + // Disabled before the session connects, so no list-changed notification is sent. + if (filter.dynamic && !includesTool(filter, tool)) handle.disable(); + registry.push({ ...tool, registered, handle }); + return handle; }; } const value: unknown = Reflect.get(target, property, target); diff --git a/test/e2e/stdio.e2e.test.ts b/test/e2e/stdio.e2e.test.ts index e5dfbf5..65e6335 100644 --- a/test/e2e/stdio.e2e.test.ts +++ b/test/e2e/stdio.e2e.test.ts @@ -514,6 +514,22 @@ describe("compiled stdio MCP boundary", () => { } }); + it("starts with a few tool groups and enables more on request", async () => { + const fake = await FakeOBSServer.start({ availableRequests: ["GetRecordStatus"] }); + fakeServers.push(fake); + const { client, stderr } = await connectClient(fake, "modern", { OBS_MCP_DYNAMIC_TOOLSETS: "true" }); + + const before = (await client.listTools()).tools.map(({ name }) => name); + await client.callTool({ name: "obs-enable-toolset", arguments: { groups: ["record"] } }); + const after = (await client.listTools()).tools.map(({ name }) => name); + + expect(before).toContain("obs-enable-toolset"); + expect(before).not.toContain("obs-start-record"); + expect(after).toContain("obs-start-record"); + expect(before.length).toBeLessThan(TOOL_COUNT / 4); + await vi.waitFor(() => expect(stderr.join("")).toMatch(/Dynamic tool groups: \d+ of \d+ tools enabled at the start/)); + }); + it("refuses to start with an unknown tool group or tool name", async () => { const environments: Record[] = [ { OBS_MCP_TOOLSETS: "scenez" }, diff --git a/test/support/mcp-harness.ts b/test/support/mcp-harness.ts index 05f6b5b..4bfdf46 100644 --- a/test/support/mcp-harness.ts +++ b/test/support/mcp-harness.ts @@ -7,6 +7,7 @@ import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; import { Client, type CallToolResult } from "@modelcontextprotocol/client"; import { OBSWebSocketClient } from "../../src/client.js"; import { initialize } from "../../src/tools/index.js"; +import type { ToolFilter } from "../../src/tools/toolsets.js"; import { FakeOBSServer, type FakeOBSOptions } from "./fake-obs-server.js"; /** An MCP client wired in memory to the real tool set and a fake OBS. */ @@ -27,6 +28,8 @@ export function resultText(result: CallToolResult): string { export type HarnessOptions = FakeOBSOptions & { /** Register tools with OBS_MCP_CONFIRM_LIVE behavior. */ confirmLive?: boolean; + /** Tool groups to register, as parsed from the OBS_MCP_* variables; defaults to every tool. */ + filter?: ToolFilter; /** Declare form elicitation and answer every elicitation with this. */ onElicit?: (message: string) => { action: "accept" | "decline" | "cancel"; @@ -35,7 +38,7 @@ export type HarnessOptions = FakeOBSOptions & { }; export async function startMcpHarness(options: HarnessOptions = {}): Promise { - const { confirmLive = false, onElicit, ...fakeOptions } = options; + const { confirmLive = false, onElicit, filter, ...fakeOptions } = options; const fakeObs = await FakeOBSServer.start(fakeOptions); const obsClient = new OBSWebSocketClient(fakeObs.url); const mcpServer = new McpServer({ name: "obs-mcp-test", version: "0.0.0" }); @@ -46,7 +49,7 @@ export async function startMcpHarness(options: HarnessOptions = {}): Promise onElicit(request.params.message)); } - initialize(mcpServer, obsClient, { confirmLive }); + initialize(mcpServer, obsClient, { confirmLive, ...(filter ? { filter } : {}) }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([mcpServer.connect(serverTransport), mcpClient.connect(clientTransport)]);