From c3c574141f3902a4275d04af92ded4368768ceb8 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Wed, 14 Oct 2026 12:20:41 -0400 Subject: [PATCH] Harden dependency installs and refresh OBS/MCP baselines --- .github/dependabot.yml | 16 + .github/workflows/ci.yml | 14 +- .github/workflows/dependency-audit.yml | 8 +- .npmrc | 3 +- NOTICE.md | 2 +- README.md | 28 +- docs/dependency-security.md | 42 + docs/protocol.md | 22 +- docs/sdk_doc.txt | 463 +------ package-lock.json | 84 +- package.json | 15 +- scripts/check-dependencies.d.mts | 5 + scripts/check-dependencies.mjs | 79 ++ scripts/pack.mjs | 4 +- src/tools/__snapshots__/tool-contract.json | 1284 ++++++-------------- test/e2e/http.e2e.test.ts | 87 ++ test/package/mcpb-artifact.test.ts | 4 + test/unit/dependency-policy.test.ts | 138 +++ 18 files changed, 897 insertions(+), 1401 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 docs/dependency-security.md create mode 100644 scripts/check-dependencies.d.mts create mode 100644 scripts/check-dependencies.mjs create mode 100644 test/unit/dependency-policy.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3ceaecb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 + versioning-strategy: increase + groups: + development: + dependency-type: development + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f829350..a8a7997 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,12 +22,13 @@ jobs: node: [20.19.0, 22.x, 24.x] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: ${{ matrix.node }} cache: npm - - run: npm ci + - run: npm run check:dependencies + - run: npm ci --ignore-scripts --no-audit - run: npm run check package: @@ -35,10 +36,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: 22.x cache: npm - - run: npm ci + - run: npm run check:dependencies + - run: npm ci --ignore-scripts --no-audit - run: npm run test:package diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index 2cca59f..fa19093 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: 22.x cache: npm - - run: npm ci - - run: npm audit --audit-level=low + - run: npm run check:dependencies + - run: npm audit --package-lock-only --ignore-scripts --audit-level=low diff --git a/.npmrc b/.npmrc index 449691b..682460b 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -save-exact=true \ No newline at end of file +save-exact=true +ignore-scripts=true diff --git a/NOTICE.md b/NOTICE.md index 09af74c..e666bfd 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -8,7 +8,7 @@ Thomas Main began the independent modernization in August 2026. Modified source The complete project is distributed under GPL-2.0-only. See `LICENSE` for the license terms. Published packages include the corresponding TypeScript source and build scripts. -The generated OBS protocol data is based on `obsproject/obs-websocket` commit `1ef34bf48110c2a18184e50e41cd0b1a855e2147`, the revision pinned by the OBS Studio baseline documented in the README. +The generated OBS protocol data is based on `obsproject/obs-websocket` commit `e28f84975984340e84fc4879cfc05741cc0afde1`, the revision pinned by the OBS Studio baseline documented in the README. Upstream references: diff --git a/README.md b/README.md index 4946a35..ace63c7 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,21 @@ The suite refuses to mutate if the selected collection does not exactly match `O Add `OBS_MCP_LIVE_RECORD=1` to the mutation command to also record a one-second clip. It checks that `obs-start-record` reports success only after OBS confirms the output is active, then stops the recording in a `finally` block. The clip is saved to the profile's recording directory and captures whatever the selected collection shows, so select a collection whose sources are safe to record. -Pull-request CI runs the hermetic gate on Node 20.19, 22, and 24, plus the packaged-artifact smoke test on Node 22. Dependency auditing runs in a separate weekly workflow so registry advisories do not make otherwise reproducible pull-request checks flaky. +Pull-request CI checks the dependency policy before installation, then runs the hermetic gate on Node 20.19, 22, and 24, plus the packaged-artifact smoke test on Node 22. Dependency auditing checks the lockfile in a separate weekly workflow so registry advisories do not make otherwise reproducible pull-request checks flaky. + +### Dependency safety + +Direct dependencies use exact versions. `npm run check:dependencies` checks that the lockfile matches the manifest, uses npm registry HTTPS tarballs with SHA-512 integrity, and contains no production dependency install scripts. The check runs offline and requires no installed packages: + +```bash +node scripts/check-dependencies.mjs +``` + +The project disables dependency lifecycle scripts through `.npmrc`; the MCPB staging install also passes `--ignore-scripts` explicitly. Explicit commands such as `npm run build` still run. Dependabot proposes weekly npm and GitHub Actions updates for review. CI actions are pinned to verified commit hashes. The residual development advisory and published SDK license findings are recorded in [`docs/dependency-security.md`](docs/dependency-security.md). + +For a dependency update, inspect its release notes and changes to the lockfile, retain exact direct versions, then run `npm run check` and `npm run test:package`. Check current advisories with `npm audit --package-lock-only --ignore-scripts --audit-level=low`. An audit reports known registry advisories; the offline policy check validates the dependency graph's packaging constraints. + +Vitest remains on 4.1.11 because Vitest 5 drops Node 20 support. Node type declarations stay on the supported Node 22 line. ### TypeScript policy @@ -328,12 +342,14 @@ The application builds with TypeScript 7.0.2 and does not import the TypeScript ## Protocol baselines -This revision was checked against these exact sources: +On October 14, 2026, this revision was compared with the `modelcontextprotocol`, `mcp-typescript-sdk`, and `obs-studio` checkouts under `/Volumes/External SSD/referencerepos`. Those checkouts were read without modification. The exact reference sources are: + +- MCP specification: [`5ea99602`](https://github.com/modelcontextprotocol/modelcontextprotocol/commit/5ea996028df322ce97d1c906731cf8c05a6e268e) +- MCP TypeScript server and client SDKs: `2.3.1`; reference checkout [`9bc40e90`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9bc40e903fde15534ec06ff4c1347d47000dbe16) +- OBS Studio: [`f082210c`](https://github.com/obsproject/obs-studio/commit/f082210c177a03572cace88c168d0f32d8cde39e) +- OBS WebSocket: [`e28f8497`](https://github.com/obsproject/obs-websocket/commit/e28f84975984340e84fc4879cfc05741cc0afde1), the revision pinned by that OBS Studio checkout -- MCP specification: [`cbd57657`](https://github.com/modelcontextprotocol/modelcontextprotocol/commit/cbd57657ec769b942263a5251afc05959fc4ce58) -- MCP TypeScript server and client SDKs: `2.0.0` -- OBS Studio: [`0043697f`](https://github.com/obsproject/obs-studio/commit/0043697fc59d791b95b3495dfa1fe180b395966f) -- OBS WebSocket: [`1ef34bf4`](https://github.com/obsproject/obs-websocket/commit/1ef34bf48110c2a18184e50e41cd0b1a855e2147), the revision pinned by that OBS Studio checkout +The refreshed OBS WebSocket revision has the same 147 requests, 60 events, and 7 enums as the previous baseline; its protocol JSON is byte-for-byte identical. MCP 2026-07-28 remains the released protocol baseline. The generated protocol files live under [`docs/`](docs/). Tests compare every request used by the tool modules with the bundled protocol, so obsolete request names and missing wrappers fail locally. diff --git a/docs/dependency-security.md b/docs/dependency-security.md new file mode 100644 index 0000000..cd411b5 --- /dev/null +++ b/docs/dependency-security.md @@ -0,0 +1,42 @@ +# Dependency security assessment + +Checked on October 14, 2026. This records a remaining dependency advisory, not an audit exemption. + +## Runtime license constraint + +The project is GPL-2.0-only. Review of the published MCP 2.0, 2.1, and 2.2 tarballs found the same mixed LICENSE text involving Apache-2.0 despite their MIT package metadata. SDK 2.3 synchronizes its package metadata with that existing license text; it does not introduce this distribution concern. Apache's [GPL compatibility guidance](https://www.apache.org/licenses/GPL-compatibility) explains the GPLv2 incompatibility. + +Returning to an earlier SDK version does not resolve the published-license discrepancy. This pass updates the source integration to SDK 2.3.1 and tests a local artifact; it does not change the project license or publish a package. + +The GPL choice follows the inherited `royshil/obs-mcp` implementation. This fork introduced explicit GPL-2.0-only metadata and source notices in August 2026. OBS Studio and OBS WebSocket explicitly permit GPLv2 or later, so their licensing alone does not establish a GPLv2-only restriction. Roy's upstream ships GPLv2 license text without a separate clear later-version grant; the sample later-version wording in the license appendix is not a project-specific grant. Establish the applicable inherited permissions before changing the combined project to GPLv3 or claiming MIT availability. This is a license-alignment question for redistribution, not a blocker on source integration or private local verification. + +The offline dependency guard checks pinned versions, registry sources, integrity, lockfile correspondence, and installation hooks. License compatibility requires a separate review of actual license documents; package metadata and version numbers are insufficient here. Development tools and files included in the source distribution also require their own assessment. + +## Remaining advisory + +The development dependency path `@anthropic-ai/mcpb@2.1.2 → node-forge@1.4.0` remains affected by high-severity [GHSA-86w9-cpqp-85rv / CVE-2026-85393](https://github.com/advisories/GHSA-86w9-cpqp-85rv). The advisory covers versions through 1.4.0 and lists no patched version. It concerns RSA PKCS#1 v1.5 signature verification accepting malformed nested DigestAlgorithm elements, which can enable signature forgery with low-exponent RSA keys. + +The installed lockfile marks both packages as development dependencies. Neither is part of the server's production dependency graph. Packaging installs production dependencies with `npm ci --omit=dev --ignore-scripts --no-audit`; the MCPB tool itself runs from the development checkout. + +## Reachability in this project + +These findings are based on the installed `@anthropic-ai/mcpb@2.1.2` source and the project's current call sites. They describe this workflow, not every application of node-forge. + +| Project operation | MCPB call path | Relevant behavior | +| --- | --- | --- | +| `npm run pack` | CLI `pack` → `packExtension` → `validateManifest` | Validates the local manifest and icon, collects local files, and creates a ZIP. It does not sign the archive or verify RSA signatures. | +| `npm run test:package` | `unpackExtension`, then `validateManifest` | Extracts the locally built artifact. Signature-footer extraction uses Buffer operations; it does not authenticate the signature or call forge verification. | +| `npm run validate:manifest` | CLI `validate` → `validateManifest` | Parses JSON, applies the manifest schema, and checks the local icon. It does not verify signatures. | +| Production MCP server | `src/index.ts` and server modules | No import or invocation of MCPB or node-forge was found. | + +MCPB imports its signing module, which imports node-forge, even when these operations do not call its cryptographic functions. Importing the package therefore does not establish that the vulnerable verification operation executes. No project invocation of `signMcpbFile` or `verifyMcpbFile` was found. + +An additional limitation exists in the installed verifier: MCPB's `verifyMcpbFile` calls `p7.verify`, but node-forge 1.4.0's [PKCS#7 verifier](https://github.com/digitalbazaar/forge/blob/v1.4.0/lib/pkcs7.js) throws because verification is unimplemented. MCPB catches that failure and reports the file as unsigned. This is not a patch for the RSA advisory, and the MCPB verifier should not be treated as a working authenticity boundary. + +## Mitigation and follow-up + +Keep the development tool out of production installations and build artifacts, keep dependency lifecycle scripts disabled, and restrict the package smoke test to the artifact built by this checkout. The current workflow does not depend on MCPB signature verification to decide whether an external bundle is trusted. + +These measures limit exposure; they do not repair node-forge or make the complete development graph vulnerability-free. The weekly audit continues to report the advisory without suppression. Recheck upstream releases and replace the affected development dependency when a reviewed fix becomes available. Any new signing or verification workflow requires a fresh assessment and a functioning independent authenticity mechanism before accepting external bundles. + +Read-only verification confirmed the development-only lockfile classification, exercised `validateManifest` without entering forge signing/RSA-key construction, and confirmed that the installed PKCS#7 verifier throws. Reachability for packing and unpacking was assessed from their source paths; this assessment is not an exploit proof or a cryptographic audit. diff --git a/docs/protocol.md b/docs/protocol.md index acada75..ebff6d6 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2663,7 +2663,7 @@ communication is desired. ## Requests Table of Contents -- [General Requests](#general-1-requests) +- [General Requests](#general-requests) - [GetVersion](#getversion) - [GetStats](#getstats) - [BroadcastCustomEvent](#broadcastcustomevent) @@ -2672,7 +2672,7 @@ communication is desired. - [TriggerHotkeyByName](#triggerhotkeybyname) - [TriggerHotkeyByKeySequence](#triggerhotkeybykeysequence) - [Sleep](#sleep) -- [Config Requests](#config-1-requests) +- [Config Requests](#config-requests) - [GetPersistentData](#getpersistentdata) - [SetPersistentData](#setpersistentdata) - [GetSceneCollectionList](#getscenecollectionlist) @@ -2694,9 +2694,9 @@ communication is desired. - [GetSourceActive](#getsourceactive) - [GetSourceScreenshot](#getsourcescreenshot) - [SaveSourceScreenshot](#savesourcescreenshot) -- [Canvases Requests](#canvases-1-requests) +- [Canvases Requests](#canvases-requests) - [GetCanvasList](#getcanvaslist) -- [Scenes Requests](#scenes-1-requests) +- [Scenes Requests](#scenes-requests) - [GetSceneList](#getscenelist) - [GetGroupList](#getgrouplist) - [GetCurrentProgramScene](#getcurrentprogramscene) @@ -2708,7 +2708,7 @@ communication is desired. - [SetSceneName](#setscenename) - [GetSceneSceneTransitionOverride](#getscenescenetransitionoverride) - [SetSceneSceneTransitionOverride](#setscenescenetransitionoverride) -- [Inputs Requests](#inputs-1-requests) +- [Inputs Requests](#inputs-requests) - [GetInputList](#getinputlist) - [GetInputKindList](#getinputkindlist) - [GetSpecialInputs](#getspecialinputs) @@ -2737,7 +2737,7 @@ communication is desired. - [SetInputDeinterlaceFieldOrder](#setinputdeinterlacefieldorder) - [GetInputPropertiesListPropertyItems](#getinputpropertieslistpropertyitems) - [PressInputPropertiesButton](#pressinputpropertiesbutton) -- [Transitions Requests](#transitions-1-requests) +- [Transitions Requests](#transitions-requests) - [GetTransitionKindList](#gettransitionkindlist) - [GetSceneTransitionList](#getscenetransitionlist) - [GetCurrentSceneTransition](#getcurrentscenetransition) @@ -2747,7 +2747,7 @@ communication is desired. - [GetCurrentSceneTransitionCursor](#getcurrentscenetransitioncursor) - [TriggerStudioModeTransition](#triggerstudiomodetransition) - [SetTBarPosition](#settbarposition) -- [Filters Requests](#filters-1-requests) +- [Filters Requests](#filters-requests) - [GetSourceFilterKindList](#getsourcefilterkindlist) - [GetSourceFilterList](#getsourcefilterlist) - [GetSourceFilterDefaultSettings](#getsourcefilterdefaultsettings) @@ -2758,7 +2758,7 @@ communication is desired. - [SetSourceFilterIndex](#setsourcefilterindex) - [SetSourceFilterSettings](#setsourcefiltersettings) - [SetSourceFilterEnabled](#setsourcefilterenabled) -- [Scene Items Requests](#scene-items-1-requests) +- [Scene Items Requests](#scene-items-requests) - [GetSceneItemList](#getsceneitemlist) - [GetGroupSceneItemList](#getgroupsceneitemlist) - [GetSceneItemId](#getsceneitemid) @@ -2776,7 +2776,7 @@ communication is desired. - [SetSceneItemIndex](#setsceneitemindex) - [GetSceneItemBlendMode](#getsceneitemblendmode) - [SetSceneItemBlendMode](#setsceneitemblendmode) -- [Outputs Requests](#outputs-1-requests) +- [Outputs Requests](#outputs-requests) - [GetVirtualCamStatus](#getvirtualcamstatus) - [ToggleVirtualCam](#togglevirtualcam) - [StartVirtualCam](#startvirtualcam) @@ -2810,12 +2810,12 @@ communication is desired. - [ResumeRecord](#resumerecord) - [SplitRecordFile](#splitrecordfile) - [CreateRecordChapter](#createrecordchapter) -- [Media Inputs Requests](#media-inputs-1-requests) +- [Media Inputs Requests](#media-inputs-requests) - [GetMediaInputStatus](#getmediainputstatus) - [SetMediaInputCursor](#setmediainputcursor) - [OffsetMediaInputCursor](#offsetmediainputcursor) - [TriggerMediaInputAction](#triggermediainputaction) -- [Ui Requests](#ui-1-requests) +- [Ui Requests](#ui-requests) - [GetStudioModeEnabled](#getstudiomodeenabled) - [SetStudioModeEnabled](#setstudiomodeenabled) - [OpenInputPropertiesDialog](#openinputpropertiesdialog) diff --git a/docs/sdk_doc.txt b/docs/sdk_doc.txt index 76343a0..ca88d25 100644 --- a/docs/sdk_doc.txt +++ b/docs/sdk_doc.txt @@ -1,424 +1,39 @@ -For Server Developers -Get started building your own server to use in Claude for Desktop and other clients. - -In this tutorial, we’ll build a simple MCP weather server and connect it to a host, Claude for Desktop. We’ll start with a basic setup, and then progress to more complex use cases. - -​ -What we’ll be building -Many LLMs do not currently have the ability to fetch the forecast and severe weather alerts. Let’s use MCP to solve that! - -We’ll build a server that exposes two tools: get-alerts and get-forecast. Then we’ll connect the server to an MCP host (in this case, Claude for Desktop): - - - -Servers can connect to any client. We’ve chosen Claude for Desktop here for simplicity, but we also have guides on building your own client as well as a list of other clients here. - - -Why Claude for Desktop and not Claude.ai? - -​ -Core MCP Concepts -MCP servers can provide three main types of capabilities: - -Resources: File-like data that can be read by clients (like API responses or file contents) -Tools: Functions that can be called by the LLM (with user approval) -Prompts: Pre-written templates that help users accomplish specific tasks -This tutorial will primarily focus on tools. - -Python -Node -Java -Kotlin -Let’s get started with building our weather server! You can find the complete code for what we’ll be building here. - -Prerequisite knowledge -This quickstart assumes you have familiarity with: - -TypeScript -LLMs like Claude -System requirements -For TypeScript, make sure you have the latest version of Node installed. - -Set up your environment -First, let’s install Node.js and npm if you haven’t already. You can download them from nodejs.org. Verify your Node.js installation: - - -Copy -node --version -npm --version -For this tutorial, you’ll need Node.js version 16 or higher. - -Now, let’s create and set up our project: - - -MacOS/Linux - -Windows - -Copy -# Create a new directory for our project -md weather -cd weather - -# Initialize a new npm project -npm init -y - -# Install dependencies -npm install @modelcontextprotocol/sdk zod -npm install -D @types/node typescript - -# Create our files -md src -new-item src\index.ts -Update your package.json to add type: “module” and a build script: - -package.json - -Copy -{ - "type": "module", - "bin": { - "weather": "./build/index.js" - }, - "scripts": { - "build": "tsc && chmod 755 build/index.js" - }, - "files": [ - "build" - ], -} -Create a tsconfig.json in the root of your project: - -tsconfig.json - -Copy -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} -Now let’s dive into building your server. - -Building your server -Importing packages and setting up the instance -Add these to the top of your src/index.ts: - - -Copy -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; - -const NWS_API_BASE = "https://api.weather.gov"; -const USER_AGENT = "weather-app/1.0"; - -// Create server instance -const server = new McpServer({ - name: "weather", - version: "1.0.0", -}); -Helper functions -Next, let’s add our helper functions for querying and formatting the data from the National Weather Service API: - - -Copy -// Helper function for making NWS API requests -async function makeNWSRequest(url: string): Promise { - const headers = { - "User-Agent": USER_AGENT, - Accept: "application/geo+json", - }; - - try { - const response = await fetch(url, { headers }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return (await response.json()) as T; - } catch (error) { - console.error("Error making NWS request:", error); - return null; - } -} - -interface AlertFeature { - properties: { - event?: string; - areaDesc?: string; - severity?: string; - status?: string; - headline?: string; - }; -} - -// Format alert data -function formatAlert(feature: AlertFeature): string { - const props = feature.properties; - return [ - `Event: ${props.event || "Unknown"}`, - `Area: ${props.areaDesc || "Unknown"}`, - `Severity: ${props.severity || "Unknown"}`, - `Status: ${props.status || "Unknown"}`, - `Headline: ${props.headline || "No headline"}`, - "---", - ].join("\n"); -} - -interface ForecastPeriod { - name?: string; - temperature?: number; - temperatureUnit?: string; - windSpeed?: string; - windDirection?: string; - shortForecast?: string; -} - -interface AlertsResponse { - features: AlertFeature[]; -} - -interface PointsResponse { - properties: { - forecast?: string; - }; -} - -interface ForecastResponse { - properties: { - periods: ForecastPeriod[]; - }; -} -Implementing tool execution -The tool execution handler is responsible for actually executing the logic of each tool. Let’s add it: - - -Copy -// Register weather tools -server.tool( - "get-alerts", - "Get weather alerts for a state", - { - state: z.string().length(2).describe("Two-letter state code (e.g. CA, NY)"), - }, - async ({ state }) => { - const stateCode = state.toUpperCase(); - const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; - const alertsData = await makeNWSRequest(alertsUrl); - - if (!alertsData) { - return { - content: [ - { - type: "text", - text: "Failed to retrieve alerts data", - }, - ], - }; - } - - const features = alertsData.features || []; - if (features.length === 0) { - return { - content: [ - { - type: "text", - text: `No active alerts for ${stateCode}`, - }, - ], - }; - } - - const formattedAlerts = features.map(formatAlert); - const alertsText = `Active alerts for ${stateCode}:\n\n${formattedAlerts.join("\n")}`; - - return { - content: [ - { - type: "text", - text: alertsText, - }, - ], - }; - }, -); - -server.tool( - "get-forecast", - "Get weather forecast for a location", - { - latitude: z.number().min(-90).max(90).describe("Latitude of the location"), - longitude: z.number().min(-180).max(180).describe("Longitude of the location"), - }, - async ({ latitude, longitude }) => { - // Get grid point data - const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; - const pointsData = await makeNWSRequest(pointsUrl); - - if (!pointsData) { - return { - content: [ - { - type: "text", - text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, - }, - ], - }; - } - - const forecastUrl = pointsData.properties?.forecast; - if (!forecastUrl) { - return { - content: [ - { - type: "text", - text: "Failed to get forecast URL from grid point data", - }, - ], - }; - } - - // Get forecast data - const forecastData = await makeNWSRequest(forecastUrl); - if (!forecastData) { - return { - content: [ - { - type: "text", - text: "Failed to retrieve forecast data", - }, - ], - }; - } - - const periods = forecastData.properties?.periods || []; - if (periods.length === 0) { - return { - content: [ - { - type: "text", - text: "No forecast periods available", - }, - ], - }; - } - - // Format forecast periods - const formattedForecast = periods.map((period: ForecastPeriod) => - [ - `${period.name || "Unknown"}:`, - `Temperature: ${period.temperature || "Unknown"}°${period.temperatureUnit || "F"}`, - `Wind: ${period.windSpeed || "Unknown"} ${period.windDirection || ""}`, - `${period.shortForecast || "No forecast available"}`, - "---", - ].join("\n"), - ); - - const forecastText = `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join("\n")}`; - - return { - content: [ - { - type: "text", - text: forecastText, - }, - ], - }; - }, -); -Running the server -Finally, implement the main function to run the server: - - -Copy -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("Weather MCP Server running on stdio"); -} - -main().catch((error) => { - console.error("Fatal error in main():", error); - process.exit(1); -}); -Make sure to run npm run build to build your server! This is a very important step in getting your server to connect. - -Let’s now test your server from an existing MCP host, Claude for Desktop. - -Testing your server with Claude for Desktop -Claude for Desktop is not yet available on Linux. Linux users can proceed to the Building a client tutorial to build an MCP client that connects to the server we just built. - -First, make sure you have Claude for Desktop installed. You can install the latest version here. If you already have Claude for Desktop, make sure it’s updated to the latest version. - -We’ll need to configure Claude for Desktop for whichever MCP servers you want to use. To do this, open your Claude for Desktop App configuration at ~/Library/Application Support/Claude/claude_desktop_config.json in a text editor. Make sure to create the file if it doesn’t exist. - -For example, if you have VS Code installed: - -MacOS/Linux -Windows - -Copy -code $env:AppData\Claude\claude_desktop_config.json -You’ll then add your servers in the mcpServers key. The MCP UI elements will only show up in Claude for Desktop if at least one server is properly configured. - -In this case, we’ll add our single weather server like so: - -MacOS/Linux -Windows - -Node - -Copy -{ - "mcpServers": { - "weather": { - "command": "node", - "args": [ - "/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather/build/index.js" - ] - } - } -} -This tells Claude for Desktop: - -There’s an MCP server named “weather” -Launch it by running node /ABSOLUTE/PATH/TO/PARENT/FOLDER/weather/build/index.js -Save the file, and restart Claude for Desktop. - -​ -Test with commands -Let’s make sure Claude for Desktop is picking up the two tools we’ve exposed in our weather server. You can do this by looking for the hammer icon: - - -After clicking on the hammer icon, you should see two tools listed: - - -If your server isn’t being picked up by Claude for Desktop, proceed to the Troubleshooting section for debugging tips. - -If the hammer icon has shown up, you can now test your server by running the following commands in Claude for Desktop: - -What’s the weather in Sacramento? -What are the active weather alerts in Texas? - - -Since this is the US National Weather service, the queries will only work for US locations. - -​ -What’s happening under the hood -When you ask a question: - -The client sends your question to Claude -Claude analyzes the available tools and decides which one(s) to use -The client executes the chosen tool(s) through the MCP server -The results are sent back to Claude -Claude formulates a natural language response -The response is displayed to you! \ No newline at end of file +MCP SDK reference for OBS MCP + +Checked: October 14, 2026. + +The previous contents of this file were an SDK v1 tutorial using +@modelcontextprotocol/sdk. OBS MCP uses the split SDK v2 packages; +package.json and package-lock.json are authoritative for installed versions. + +Reference checkout (read-only): +/Volumes/External SSD/referencerepos/mcp-typescript-sdk +Commit: 9bc40e903fde15534ec06ff4c1347d47000dbe16 +https://github.com/modelcontextprotocol/typescript-sdk/tree/9bc40e903fde15534ec06ff4c1347d47000dbe16 + +Official guides: +- https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/docs/migration/upgrade-to-v2.md +- https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/docs/migration/support-2026-07-28.md +- https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/server/CHANGELOG.md +- https://github.com/modelcontextprotocol/typescript-sdk/blob/9bc40e903fde15534ec06ff4c1347d47000dbe16/packages/client/CHANGELOG.md + +Project integration: +- src/server.ts builds fresh McpServer instances with registered OBS tools. +- serveStdio(createServer) supports the released 2026-07-28 protocol and + legacy clients. Keep the factory; do not reuse a single server instance. +- src/http.ts passes the factory to createMcpHandler. Stateless HTTP + requests receive their own server/transport while sharing the OBS client. +- src/http.ts validates loopback Host/Origin headers and optionally checks + an opaque shared bearer token. It does not implement OAuth token issuance. +- src/tools/request-tool.ts normalizes tool results and approval annotations. +- scripts/list-tools.mjs retrieves tool inventory through the public MCP + interface; packaging must not depend on private SDK internals. + +SDK 2.1 and later bound HTTP request bodies and legacy batch size by default. +The HTTP regression suite verifies those limits and subsequent availability. +SDK 2.3 tightens server/transport ownership; this project's factory pattern +already satisfies it. New optional argument-element and OAuth-audience +settings require an explicit integration decision before use. + +License and residual advisory assessment: docs/dependency-security.md. +Package metadata alone does not establish redistribution permission. diff --git a/package-lock.json b/package-lock.json index 156fbf3..561278c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,18 @@ "version": "1.1.1", "license": "GPL-2.0-only", "dependencies": { - "@modelcontextprotocol/server": "2.0.0", - "ws": "8.21.3", - "zod": "4.4.3" + "@modelcontextprotocol/server": "2.3.1", + "ws": "8.22.0", + "zod": "4.6.7" }, "bin": { "obs-mcp": "build/index.js" }, "devDependencies": { "@anthropic-ai/mcpb": "2.1.2", - "@modelcontextprotocol/client": "2.0.0", - "@types/node": "22.20.1", - "@types/ws": "8.18.1", + "@modelcontextprotocol/client": "2.3.1", + "@types/node": "22.20.5", + "@types/ws": "8.18.2", "typescript": "7.0.2", "vitest": "4.1.11" }, @@ -411,16 +411,16 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", - "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.1", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", + "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" @@ -430,10 +430,10 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", "dependencies": { "zod": "^4.2.0" }, @@ -442,12 +442,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" }, "engines": { @@ -777,9 +777,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "dev": true, "license": "MIT", "dependencies": { @@ -787,9 +787,9 @@ } }, "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1377,9 +1377,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, "license": "MIT", "engines": { @@ -1442,9 +1442,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "dev": true, "license": "MIT" }, @@ -2088,9 +2088,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2409,9 +2409,9 @@ } }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2430,9 +2430,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index ab28b91..9b0b1e0 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "test:package:run": "vitest run --config vitest.package.config.ts", "test:obs-live": "npm run build && vitest run --config vitest.live.config.ts", "validate:manifest": "mcpb validate manifest.json", - "check": "npm run build && npm run typecheck:test && npm run test:unit && npm run test:e2e:run && npm run validate:manifest" + "check:dependencies": "node scripts/check-dependencies.mjs", + "check": "npm run check:dependencies && npm run build && npm run typecheck:test && npm run test:unit && npm run test:e2e:run && npm run validate:manifest" }, "files": [ "build", @@ -63,15 +64,15 @@ "NOTICE.md" ], "dependencies": { - "ws": "8.21.3", - "zod": "4.4.3", - "@modelcontextprotocol/server": "2.0.0" + "@modelcontextprotocol/server": "2.3.1", + "ws": "8.22.0", + "zod": "4.6.7" }, "devDependencies": { "@anthropic-ai/mcpb": "2.1.2", - "@modelcontextprotocol/client": "2.0.0", - "@types/node": "22.20.1", - "@types/ws": "8.18.1", + "@modelcontextprotocol/client": "2.3.1", + "@types/node": "22.20.5", + "@types/ws": "8.18.2", "typescript": "7.0.2", "vitest": "4.1.11" }, diff --git a/scripts/check-dependencies.d.mts b/scripts/check-dependencies.d.mts new file mode 100644 index 0000000..0b69d62 --- /dev/null +++ b/scripts/check-dependencies.d.mts @@ -0,0 +1,5 @@ +export function validateDependencyPolicy( + manifest: Record, + lock: Record, +): { packageCount: number }; +export function checkDependencies(options?: { root?: string }): { packageCount: number }; diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs new file mode 100644 index 0000000..ed2f3eb --- /dev/null +++ b/scripts/check-dependencies.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const exactVersion = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies"]; + +function sameDependencies(left = {}, right = {}) { + return Object.keys(left).length === Object.keys(right).length + && Object.entries(left).every(([name, version]) => right[name] === version); +} + +/** Validate the graph without installing packages or contacting a registry. */ +export function validateDependencyPolicy(manifest, lock) { + const errors = []; + if (lock.lockfileVersion !== 3) errors.push("The dependency lockfile must use version 3."); + const packages = lock.packages; + const root = packages?.[""]; + if (!root) errors.push("The lockfile must contain root package metadata."); + if (lock.name !== manifest.name || lock.version !== manifest.version + || root?.name !== manifest.name || root?.version !== manifest.version) { + errors.push("Package name/version must match the lockfile and its root metadata."); + } + for (const field of dependencyFields) { + const dependencies = manifest[field] ?? {}; + if (!sameDependencies(dependencies, root?.[field])) { + errors.push(`${field} must match the lockfile root metadata.`); + } + for (const [name, version] of Object.entries(dependencies)) { + if (typeof version !== "string" || !exactVersion.test(version)) { + errors.push(`${field}.${name} must use an exact version.`); + } + if (packages?.[`node_modules/${name}`]?.version !== version) { + errors.push(`${field}.${name} must match its installed lockfile entry.`); + } + } + } + for (const [location, entry] of Object.entries(packages ?? {})) { + if (location === "") continue; + if (!location.startsWith("node_modules/") || entry.link) { + errors.push(`${location}: local/workspace links are not allowed.`); + } + let source; + try { source = new URL(entry.resolved); } catch { /* Report as an invalid source below. */ } + if (!source || source.protocol !== "https:" || source.hostname !== "registry.npmjs.org" + || source.port || source.username || source.password || source.search || source.hash + || !source.pathname.endsWith(".tgz")) { + errors.push(`${location}: dependencies must resolve to HTTPS npm registry tarballs.`); + } + if (typeof entry.integrity !== "string" || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(entry.integrity)) { + errors.push(`${location}: dependencies must have SHA-512 integrity.`); + } + // fsevents is an optional macOS watcher used by development tooling. Its + // install hook stays disabled; every new hook requires an explicit review. + if (entry.hasInstallScript && !(location === "node_modules/fsevents" + && entry.version === "2.3.3" && entry.dev === true && entry.optional === true)) { + errors.push(`${location}: unreviewed dependency install scripts are not allowed.`); + } + } + if (errors.length) throw new Error(`Dependency policy failed:\n${errors.map((error) => `- ${error}`).join("\n")}`); + return { packageCount: Object.keys(packages).length - 1 }; +} + +export function checkDependencies({ root = defaultRoot } = {}) { + const readJson = (file) => JSON.parse(readFileSync(resolve(root, file), "utf8")); + return validateDependencyPolicy(readJson("package.json"), readJson("package-lock.json")); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { packageCount } = checkDependencies(); + console.log(`Dependency policy passed (${packageCount} locked packages).`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/pack.mjs b/scripts/pack.mjs index 74b7651..357b331 100644 --- a/scripts/pack.mjs +++ b/scripts/pack.mjs @@ -16,6 +16,7 @@ import { import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { listTools } from "./list-tools.mjs"; +import { checkDependencies } from "./check-dependencies.mjs"; const require = createRequire(import.meta.url); const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -33,6 +34,7 @@ export async function pack({ output = resolve(root, "dist/obs-studio.mcpb"), keepStaging = false, } = {}) { + checkDependencies({ root }); const staging = resolve(root, ".pack-staging"); console.log("Preparing staging directory..."); rmSync(staging, { recursive: true, force: true }); @@ -90,7 +92,7 @@ export async function pack({ }); console.log("Installing production dependencies..."); - execFileSync(npmCommand(), ["ci", "--omit=dev"], { + execFileSync(npmCommand(), ["ci", "--omit=dev", "--ignore-scripts", "--no-audit"], { cwd: staging, stdio: "inherit", }); diff --git a/src/tools/__snapshots__/tool-contract.json b/src/tools/__snapshots__/tool-contract.json index f7fa6a0..d1c7e15 100644 --- a/src/tools/__snapshots__/tool-contract.json +++ b/src/tools/__snapshots__/tool-contract.json @@ -818,13 +818,9 @@ "properties": { "sceneItemId": { "description": "Numeric ID of the duplicated scene item", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -889,46 +885,30 @@ "properties": { "sceneName": { "description": "Current program scene name", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sceneUuid": { "description": "Current program scene UUID", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentProgramSceneName": { "description": "Current program scene name (Deprecated)", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentProgramSceneUuid": { "description": "Current program scene UUID (Deprecated)", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -954,68 +934,44 @@ "properties": { "transitionName": { "description": "Name of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionUuid": { "description": "UUID of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionKind": { "description": "Kind of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionFixed": { "description": "Whether the transition uses a fixed (unconfigurable) duration", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionDuration": { "description": "Configured transition duration in milliseconds. `null` if transition is fixed", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "transitionConfigurable": { "description": "Whether the transition supports being configured", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionSettings": { @@ -1268,13 +1224,9 @@ "properties": { "inputAudioBalance": { "description": "Audio balance value from 0.0-1.0", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -1309,13 +1261,9 @@ "properties": { "monitorType": { "description": "Audio monitor type", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -1350,13 +1298,9 @@ "properties": { "inputAudioSyncOffset": { "description": "Audio sync offset in milliseconds", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -1483,13 +1427,9 @@ "properties": { "inputDeinterlaceFieldOrder": { "description": "Deinterlace field order of the input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -1525,13 +1465,9 @@ "properties": { "inputDeinterlaceMode": { "description": "Deinterlace mode of the input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -1606,13 +1542,9 @@ "properties": { "inputMuted": { "description": "Whether the input is muted", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] } }, @@ -1718,13 +1650,9 @@ }, "inputKind": { "description": "The kind of the input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -1759,24 +1687,16 @@ "properties": { "inputVolumeMul": { "description": "Volume setting in mul", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "inputVolumeDb": { "description": "Volume setting in dB", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -1802,13 +1722,9 @@ "properties": { "savedReplayPath": { "description": "File path", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -1843,35 +1759,23 @@ "properties": { "mediaState": { "description": "State of the media input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mediaDuration": { "description": "Total duration of the playing media in milliseconds. `null` if not playing", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "mediaCursor": { "description": "Position of the cursor in milliseconds. `null` if not playing", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -2029,90 +1933,58 @@ "properties": { "outputActive": { "description": "Whether the output is active", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "outputReconnecting": { "description": "Whether the output is reconnecting", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "outputTimecode": { "description": "Current formatted timecode string for the output", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "outputDuration": { "description": "Current duration in milliseconds for the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputCongestion": { "description": "Congestion of the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputBytes": { "description": "Number of bytes sent by the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputSkippedFrames": { "description": "Number of frames skipped by the output's process", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputTotalFrames": { "description": "Total number of frames delivered by the output's process", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -2176,46 +2048,30 @@ "properties": { "sceneName": { "description": "Current preview scene name", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sceneUuid": { "description": "Current preview scene UUID", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentPreviewSceneName": { "description": "Current preview scene name", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentPreviewSceneUuid": { "description": "Current preview scene UUID", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -2241,13 +2097,9 @@ "properties": { "currentProfileName": { "description": "The name of the current profile", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "profiles": { @@ -2301,24 +2153,16 @@ "properties": { "parameterValue": { "description": "Value associated with the parameter. `null` if not set and no default", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "defaultParameterValue": { "description": "Default value associated with the parameter. `null` if no default", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -2344,13 +2188,9 @@ "properties": { "recordDirectory": { "description": "Output directory", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -2376,57 +2216,37 @@ "properties": { "outputActive": { "description": "Whether the output is active", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "outputPaused": { "description": "Whether the output is paused", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "outputTimecode": { "description": "Current formatted timecode string for the output", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "outputDuration": { "description": "Current duration in milliseconds for the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputBytes": { "description": "Number of bytes sent by the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -2452,13 +2272,9 @@ "properties": { "outputActive": { "description": "Whether the output is active", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] } }, @@ -2484,13 +2300,9 @@ "properties": { "currentSceneCollectionName": { "description": "The name of the current scene collection", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sceneCollections": { @@ -2553,13 +2365,9 @@ "properties": { "sceneItemBlendMode": { "description": "Current blend mode", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -2608,13 +2416,9 @@ "properties": { "sceneItemEnabled": { "description": "Whether the scene item is enabled. `true` for enabled, `false` for disabled", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] } }, @@ -2668,13 +2472,9 @@ "properties": { "sceneItemId": { "description": "Numeric ID of the scene item", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -2723,13 +2523,9 @@ "properties": { "sceneItemIndex": { "description": "Index position of the scene item", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -2778,13 +2574,9 @@ "properties": { "sceneItemLocked": { "description": "Whether the scene item is locked. `true` for locked, `false` for unlocked", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] } }, @@ -2833,24 +2625,16 @@ "properties": { "sourceName": { "description": "Name of the source associated with the scene item", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sourceUuid": { "description": "UUID of the source associated with the scene item", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -2994,46 +2778,30 @@ "properties": { "currentProgramSceneName": { "description": "Current program scene name. Can be `null` if non-main canvas or internal state desync", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentProgramSceneUuid": { "description": "Current program scene UUID. Can be `null` if non-main canvas or internal state desync", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentPreviewSceneName": { "description": "Current preview scene name. `null` if not in studio mode or non-main canvas", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentPreviewSceneUuid": { "description": "Current preview scene UUID. `null` if not in studio mode or non-main canvas", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "scenes": { @@ -3091,24 +2859,16 @@ "properties": { "transitionName": { "description": "Name of the overridden scene transition, else `null`", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionDuration": { "description": "Duration of the overridden scene transition, else `null`", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -3148,24 +2908,16 @@ "properties": { "videoActive": { "description": "Whether the source is showing in Program", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "videoShowing": { "description": "Whether the source is showing in the UI (Preview, Projector, Properties)", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] } }, @@ -3209,35 +2961,23 @@ "properties": { "filterEnabled": { "description": "Whether the filter is enabled", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "filterIndex": { "description": "Index of the filter in the list, beginning at 0", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "filterKind": { "description": "The kind of filter", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "filterSettings": { @@ -3404,68 +3144,44 @@ "properties": { "desktop1": { "description": "Name of the Desktop Audio input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "desktop2": { "description": "Name of the Desktop Audio 2 input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mic1": { "description": "Name of the Mic/Auxiliary Audio input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mic2": { "description": "Name of the Mic/Auxiliary Audio 2 input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mic3": { "description": "Name of the Mic/Auxiliary Audio 3 input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mic4": { "description": "Name of the Mic/Auxiliary Audio 4 input", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -3491,123 +3207,79 @@ "properties": { "cpuUsage": { "description": "Current CPU usage in percent", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "memoryUsage": { "description": "Amount of memory in MB currently being used by OBS", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "availableDiskSpace": { "description": "Available disk space on the device being used for recording storage", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "activeFps": { "description": "Current FPS being rendered", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "averageFrameRenderTime": { "description": "Average time in milliseconds that OBS is taking to render a frame", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "renderSkippedFrames": { "description": "Number of frames skipped by OBS in the render thread", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "renderTotalFrames": { "description": "Total number of frames outputted by the render thread", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputSkippedFrames": { "description": "Number of frames skipped by OBS in the output thread", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputTotalFrames": { "description": "Total number of frames outputted by the output thread", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "webSocketSessionIncomingMessages": { "description": "Total number of messages received by obs-websocket from the client", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "webSocketSessionOutgoingMessages": { "description": "Total number of messages sent by obs-websocket to the client", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -3647,13 +3319,9 @@ "properties": { "streamServiceType": { "description": "Stream service type, like `rtmp_custom` or `rtmp_common`", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "streamServiceSettings": { @@ -3694,90 +3362,58 @@ "properties": { "outputActive": { "description": "Whether the output is active", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "outputReconnecting": { "description": "Whether the output is currently reconnecting", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "outputTimecode": { "description": "Current formatted timecode string for the output", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "outputDuration": { "description": "Current duration in milliseconds for the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputCongestion": { "description": "Congestion of the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputBytes": { "description": "Number of bytes sent by the output", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputSkippedFrames": { "description": "Number of frames skipped by the output's process", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputTotalFrames": { "description": "Total number of frames delivered by the output's process", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -3803,13 +3439,9 @@ "properties": { "studioModeEnabled": { "description": "Whether studio mode is enabled", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] } }, @@ -3835,13 +3467,9 @@ "properties": { "transitionCursor": { "description": "Cursor position, between 0.0 and 1.0", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -3867,68 +3495,44 @@ "properties": { "transitionName": { "description": "Name of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionUuid": { "description": "UUID of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionKind": { "description": "Kind of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionFixed": { "description": "Whether the transition uses a fixed (unconfigurable) duration", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionDuration": { "description": "Configured transition duration in milliseconds. `null` if transition is fixed", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "transitionConfigurable": { "description": "Whether the transition supports being configured", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionSettings": { @@ -3969,68 +3573,44 @@ "properties": { "transitionName": { "description": "Name of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionUuid": { "description": "UUID of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionKind": { "description": "Kind of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionFixed": { "description": "Whether the transition uses a fixed (unconfigurable) duration", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionDuration": { "description": "Configured transition duration in milliseconds. `null` if transition is fixed", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "transitionConfigurable": { "description": "Whether the transition supports being configured", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionSettings": { @@ -4106,35 +3686,23 @@ "properties": { "currentSceneTransitionName": { "description": "Name of the current scene transition. Can be null", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentSceneTransitionUuid": { "description": "UUID of the current scene transition. Can be null", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "currentSceneTransitionKind": { "description": "Kind of the current scene transition. Can be null", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitions": { @@ -4178,68 +3746,44 @@ "properties": { "transitionName": { "description": "Name of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionUuid": { "description": "UUID of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionKind": { "description": "Kind of the transition", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionFixed": { "description": "Whether the transition uses a fixed (unconfigurable) duration", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionDuration": { "description": "Configured transition duration in milliseconds. `null` if transition is fixed", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "transitionConfigurable": { "description": "Whether the transition supports being configured", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] }, "transitionSettings": { @@ -4280,35 +3824,23 @@ "properties": { "obsVersion": { "description": "Current OBS Studio version", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "obsWebSocketVersion": { "description": "Current obs-websocket version", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "rpcVersion": { "description": "Current latest obs-websocket RPC version", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "availableRequests": { @@ -4341,24 +3873,16 @@ }, "platform": { "description": "Name of the platform. Usually `windows`, `macos`, or `ubuntu` (linux flavor). Not guaranteed to be any of those", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "platformDescription": { "description": "Description of the platform, like `Windows 10 (10.0)`", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] } }, @@ -4384,68 +3908,44 @@ "properties": { "fpsNumerator": { "description": "Numerator of the fractional FPS value", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "fpsDenominator": { "description": "Denominator of the fractional FPS value", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "baseWidth": { "description": "Width of the base (canvas) resolution in pixels", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "baseHeight": { "description": "Height of the base (canvas) resolution in pixels", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputWidth": { "description": "Width of the output resolution in pixels", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] }, "outputHeight": { "description": "Height of the output resolution in pixels", - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } + "type": [ + "number", + "null" ] } }, @@ -4471,13 +3971,9 @@ "properties": { "outputActive": { "description": "Whether the output is active", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } + "type": [ + "boolean", + "null" ] } }, @@ -6037,15 +5533,11 @@ "description": "Name of the parameter to set" }, "parameterValue": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Value of the parameter to set. Use null to delete" + "description": "Value of the parameter to set. Use null to delete", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -6470,13 +5962,9 @@ }, "transitionName": { "description": "Transition name, or null to clear it", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "transitionDuration": { diff --git a/test/e2e/http.e2e.test.ts b/test/e2e/http.e2e.test.ts index ecbc61c..5cfae4d 100644 --- a/test/e2e/http.e2e.test.ts +++ b/test/e2e/http.e2e.test.ts @@ -60,6 +60,34 @@ function rawPost(url: string, headers: Record): Promise }); } +async function postJson(url: string, body: unknown, headers: Record = {}) { + const target = new URL(url); + return await new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = http.request({ + host: target.hostname, + port: target.port, + path: target.pathname, + method: "POST", + headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers }, + }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { body += chunk; }); + response.on("end", () => resolve({ status: response.statusCode ?? 0, body })); + response.on("error", reject); + }); + request.on("error", reject); + request.setTimeout(5_000, () => request.destroy(new Error("HTTP test request timed out"))); + request.end(JSON.stringify(body)); + }); +} + +const modernMeta = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: "http-regression-test", version: "1.0.0" }, +}; + function text(result: CallToolResult): string { return result.content.map((block) => (block.type === "text" ? block.text : "")).join("\n"); } @@ -71,6 +99,65 @@ afterEach(async () => { }); describe("compiled HTTP server", () => { + it.each(["modern", "legacy"])("rejects oversized %s request bodies and remains available", async (era) => { + const fake = await FakeOBSServer.start(); + fakes.push(fake); + const url = await startHttpServer(fake); + const body = { + jsonrpc: "2.0", id: 1, method: "tools/list", + params: { ...(era === "modern" ? { _meta: modernMeta } : {}), padding: "x".repeat(4 * 1024 * 1024) }, + }; + const headers: Record = era === "modern" + ? { "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" } + : { "mcp-protocol-version": "2025-11-25" }; + + // A chunked upload exercises the bounded reader without a Content-Length shortcut. + const response = await postJson(url, body, { ...headers, "transfer-encoding": "chunked" }); + expect(response.status).toBe(413); + expect(JSON.parse(response.body).error.message).toContain("Payload Too Large"); + + const call = await agent(url, `after-large-${era}`); + expect((await call("obs-control-status")).isError).toBeFalsy(); + await vi.waitFor(async () => expect(text(await call("obs-get-status"))).toContain('"connected": true'), { timeout: 3_000 }); + }); + + it("rejects legacy batches exceeding 100 messages and remains available", async () => { + const fake = await FakeOBSServer.start(); + fakes.push(fake); + const url = await startHttpServer(fake); + const batch = Array.from({ length: 101 }, (_, id) => ({ jsonrpc: "2.0", id, method: "tools/list", params: {} })); + const response = await postJson(url, batch, { "mcp-protocol-version": "2025-11-25" }); + expect(response.status).toBe(400); + expect(JSON.parse(response.body).error).toMatchObject({ code: -32600, message: expect.stringContaining("100 messages") }); + + const call = await agent(url, "after-large-batch"); + expect((await call("obs-control-status")).isError).toBeFalsy(); + await vi.waitFor(async () => expect(text(await call("obs-get-status"))).toContain('"connected": true'), { timeout: 3_000 }); + }); + + it("serves concurrent discovery requests with independent server instances", async () => { + const fake = await FakeOBSServer.start(); + fakes.push(fake); + const url = await startHttpServer(fake); + const responses = await Promise.all(Array.from({ length: 8 }, (_, id) => postJson(url, { + jsonrpc: "2.0", id, method: "server/discover", params: { _meta: modernMeta }, + }, { "mcp-protocol-version": "2026-07-28", "mcp-method": "server/discover" }))); + + for (const [id, response] of responses.entries()) { + expect(response.status).toBe(200); + // Discovery is an ordinary response stream; consume its complete result before reconnecting. + const event = response.body.split("\n").find((line) => line.startsWith("data: ")); + const result = JSON.parse(event ? event.slice(6) : response.body); + expect(result.id).toBe(id); + expect(result.error).toBeUndefined(); + expect(result.result.supportedVersions).toContain("2026-07-28"); + } + + const call = await agent(url, "after-concurrent-discovery"); + expect((await call("obs-control-status")).isError).toBeFalsy(); + await vi.waitFor(async () => expect(text(await call("obs-get-status"))).toContain('"connected": true'), { timeout: 3_000 }); + }); + it("lets two agents share one server and one OBS connection, with a control lease", async () => { const fake = await FakeOBSServer.start(); fakes.push(fake); diff --git a/test/package/mcpb-artifact.test.ts b/test/package/mcpb-artifact.test.ts index 9919654..3a17e8d 100644 --- a/test/package/mcpb-artifact.test.ts +++ b/test/package/mcpb-artifact.test.ts @@ -91,6 +91,10 @@ describe("MCPB artifact", () => { await expect(access(path.join(extracted, relativePath), constants.R_OK)).resolves.toBeUndefined(); } await expect(access(path.join(extracted, "server/index.js"), constants.X_OK)).resolves.toBeUndefined(); + for (const developmentDependency of ["@anthropic-ai/mcpb", "node-forge"]) { + await expect(access(path.join(extracted, "node_modules", developmentDependency))) + .rejects.toMatchObject({ code: "ENOENT" }); + } const fake = await FakeOBSServer.start(); fakeServers.push(fake); diff --git a/test/unit/dependency-policy.test.ts b/test/unit/dependency-policy.test.ts new file mode 100644 index 0000000..c7cdd90 --- /dev/null +++ b/test/unit/dependency-policy.test.ts @@ -0,0 +1,138 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { validateDependencyPolicy } from "../../scripts/check-dependencies.mjs"; + +const integrity = `sha512-${Buffer.alloc(64).toString("base64")}`; + +function fixture() { + const manifest = { + name: "dependency-policy-fixture", + version: "1.0.0", + dependencies: { ws: "8.21.3" }, + devDependencies: { fsevents: "2.3.3" }, + }; + const lock = { + name: manifest.name, + version: manifest.version, + lockfileVersion: 3, + packages: { + "": structuredClone(manifest), + "node_modules/ws": { + version: "8.21.3", + resolved: "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + integrity, + hasInstallScript: false, + dev: false, + optional: false, + link: false, + }, + "node_modules/fsevents": { + version: "2.3.3", + resolved: "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + integrity, + hasInstallScript: true, + dev: true, + optional: true, + link: false, + }, + }, + }; + return { manifest, lock }; +} + +describe("offline dependency policy", () => { + it("accepts the pinned registry graph and reviewed optional development watcher", () => { + const { manifest, lock } = fixture(); + expect(validateDependencyPolicy(manifest, lock)).toEqual({ packageCount: 2 }); + }); + + it("accepts the current project graph without an install", () => { + const readJson = (file: string): Record => JSON.parse( + readFileSync(new URL(`../../${file}`, import.meta.url), "utf8"), + ) as Record; + expect(validateDependencyPolicy(readJson("package.json"), readJson("package-lock.json"))) + .toEqual({ packageCount: expect.any(Number) }); + }); + + it.each(["^8.21.3", "~8.21.3", "*", "latest", "npm:ws@8.21.3", "file:../ws", "github:example/ws"])( + "rejects non-exact direct dependency %s even when root metadata agrees", + (version) => { + const { manifest, lock } = fixture(); + manifest.dependencies.ws = version; + lock.packages[""].dependencies.ws = version; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("must use an exact version"); + }, + ); + + it.each([ + "http://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "https://registry.npmjs.org.example.org/ws.tgz", + "https://example.org/ws.tgz", + "https://user:password@registry.npmjs.org/ws.tgz", + "https://registry.npmjs.org:444/ws.tgz", + "https://registry.npmjs.org/ws.tgz?token=example", + "https://registry.npmjs.org/ws.tgz#fragment", + "git+https://github.com/example/ws.git", + "file:../ws", + "https://registry.npmjs.org/ws/package.json", + ])("rejects untrusted dependency source %s", (resolved) => { + const { manifest, lock } = fixture(); + lock.packages["node_modules/ws"].resolved = resolved; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("HTTPS npm registry tarballs"); + }); + + it.each(["", "sha1-abcdef", "sha512-invalid", "sha512-"])("rejects invalid integrity %s", (value) => { + const { manifest, lock } = fixture(); + lock.packages["node_modules/ws"].integrity = value; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("SHA-512 integrity"); + }); + + it("rejects a different lockfile format", () => { + const { manifest, lock } = fixture(); + lock.lockfileVersion = 2; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("version 3"); + }); + + it("rejects stale package identity", () => { + const { manifest, lock } = fixture(); + lock.version = "0.9.0"; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("name/version must match"); + }); + + it("rejects divergent dependency root metadata", () => { + const { manifest, lock } = fixture(); + lock.packages[""].dependencies.ws = "8.21.2"; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("dependencies must match"); + }); + + it("rejects a direct dependency resolving to a different locked version", () => { + const { manifest, lock } = fixture(); + lock.packages["node_modules/ws"].version = "8.21.2"; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("installed lockfile entry"); + }); + + it("rejects workspace links even with registry metadata", () => { + const { manifest, lock } = fixture(); + lock.packages["node_modules/ws"].link = true; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("links are not allowed"); + }); + + it("rejects production installation hooks", () => { + const { manifest, lock } = fixture(); + lock.packages["node_modules/ws"].hasInstallScript = true; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("install scripts are not allowed"); + }); + + it("rejects new installation hooks even if marked development-only", () => { + const { manifest, lock } = fixture(); + lock.packages["node_modules/ws"].hasInstallScript = true; + lock.packages["node_modules/ws"].dev = true; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("install scripts are not allowed"); + }); + + it.each(["dev", "optional"] as const)("requires the reviewed watcher to remain %s", (flag) => { + const { manifest, lock } = fixture(); + lock.packages["node_modules/fsevents"][flag] = false; + expect(() => validateDependencyPolicy(manifest, lock)).toThrow("install scripts are not allowed"); + }); +});