Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
}
},
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
82 changes: 82 additions & 0 deletions src/tools/toolset-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<McpHarness> {
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<string[]> {
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");
});
});
104 changes: 104 additions & 0 deletions src/tools/toolset-tools.ts
Original file line number Diff line number Diff line change
@@ -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<ToolGroup, string> = {
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();
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Batch tool-list notifications for group updates

When enabling or disabling a group containing multiple tools, this loop invokes an SDK handle once per tool; each enable() or disable() emits notifications/tools/list_changed. A client that responds by calling tools/list will therefore reload the full tool inventory dozens鈥攁nd potentially up to 168鈥攖imes for one obs-enable-toolset call. Batch or suppress these per-tool notifications and emit a single list-change notification after the entire update.

Useful? React with 馃憤聽/ 馃憥.

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<CallToolResult> => {
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<CallToolResult> => {
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);
}
2 changes: 1 addition & 1 deletion src/tools/toolsets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
45 changes: 36 additions & 9 deletions src/tools/toolsets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolGroup> | null;
tools: ReadonlySet<string>;
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[] {
Expand All @@ -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<ToolGroup>();
for (const name of groupNames) {
Expand All @@ -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<RegisteredTool, "name" | "group" | "annotations">): boolean {
if (filter.readOnly && tool.annotations?.readOnlyHint !== true) return false;
return filter.dynamic ? true : includesTool(filter, tool);
}

export function includesTool(filter: ToolFilter, tool: Omit<RegisteredTool, "registered">): boolean {
/** Whether a tool is registered, or with dynamic toolsets, enabled at the start. */
export function includesTool(filter: ToolFilter, tool: Pick<RegisteredTool, "name" | "group" | "annotations">): 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;
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions test/e2e/stdio.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>[] = [
{ OBS_MCP_TOOLSETS: "scenez" },
Expand Down
Loading
Loading