Skip to content
Open
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
16 changes: 15 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,36 @@ node_modules
.pnp.js

# testing
coverage
coverage/
.nyc_output/
test-results/
playwright-report/
blob-report/
junit*.xml

# next.js
.next/
out/
build

# build / compile artifacts
dist/
*.tsbuildinfo
tsconfig.tsbuildinfo
!apps/mcp-server/dist/
!apps/mcp-server/dist/**

# misc
.DS_Store
*.pem
*.lock

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
*.log

# local env files
.env.local
Expand Down
71 changes: 71 additions & 0 deletions apps/mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# DumbCode Studio MCP Server

This package exposes DumbCode Studio as a local Model Context Protocol server. It forwards MCP tool calls to the Studio REST bridge under `/api/mcp/*`; the open Studio browser tab polls that bridge and executes commands inside the real React/Three Studio state.

## Run

From the repository root:

```powershell
yarn install
yarn workspace dumbcode-studio-mcp build
$env:DCS_STUDIO_URL="http://localhost:3000"; yarn workspace dumbcode-studio-mcp start
```

Start Studio separately and keep it open in a browser:

```powershell
yarn workspace studio dev
```

Then visit `http://localhost:3000`. The browser tab registers itself with `/api/mcp/health`.

## Optional bearer token

To require a token on external MCP-to-Studio REST calls, start both the MCP server and Studio with the same environment value:

```powershell
$env:DCS_MCP_TOKEN="your-long-random-token"; $env:DCS_STUDIO_URL="http://localhost:3000"; yarn workspace dumbcode-studio-mcp start
```

## MCP host configuration

Example stdio configuration:

```json
{
"mcpServers": {
"dumbcode-studio": {
"command": "node",
"args": ["/absolute/path/to/DumbCode-Studio/apps/mcp-server/dist/index.js"],
"env": {
"DCS_STUDIO_URL": "http://localhost:3000"
}
}
}
}
```

With bearer authentication enabled:

```json
{
"mcpServers": {
"dumbcode-studio": {
"command": "node",
"args": ["/absolute/path/to/DumbCode-Studio/apps/mcp-server/dist/index.js"],
"env": {
"DCS_STUDIO_URL": "http://localhost:3000",
"DCS_MCP_TOKEN": "your-long-random-token"
}
}
}
}
```

## Main tools

- `dcs_status` checks whether Studio is reachable and whether a browser bridge is connected.
- `dcs_get_action_schema` returns every available Studio action and argument contract.
- `dcs_action` executes any action by name.
- `dcs_<action>` tools expose each action directly, for example `dcs_create_cube`, `dcs_create_animation`, `dcs_set_keyframe_transform`, and `dcs_export_asset`.
2 changes: 2 additions & 0 deletions apps/mcp-server/dist/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
export {};
96 changes: 96 additions & 0 deletions apps/mcp-server/dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mcp-server/dist/index.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions apps/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "dumbcode-studio-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"bin": {
"dumbcode-studio-mcp": "dist/index.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.12.12",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
},
"engines": {
"node": ">=18.18.0"
}
}
121 changes: 121 additions & 0 deletions apps/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod"
import { MCP_ACTION_DESCRIPTIONS, MCP_ACTION_SCHEMA, MCP_ACTIONS, type McpActionName } from "../../../packages/mcp-contract/index.js"

const studioBaseUrl = (process.env.DCS_STUDIO_URL ?? "http://localhost:3000").replace(/\/$/, "")
const bearerToken = process.env.DCS_MCP_TOKEN

const asUrl = (path: string) => new URL(path, `${studioBaseUrl}/`).toString()

const requestStudio = async (path: string, init: RequestInit = {}) => {
const headers = new Headers(init.headers)
if (bearerToken) headers.set("Authorization", `Bearer ${bearerToken}`)
if (init.body !== undefined && !headers.has("Content-Type")) headers.set("Content-Type", "application/json")

const response = await fetch(asUrl(path), { ...init, headers })
const text = await response.text()
let body: unknown = text
if (text) {
try {
body = JSON.parse(text)
} catch {
body = text
}
}

if (!response.ok) {
const message = typeof body === "object" && body !== null && "error" in body ? String((body as { error: unknown }).error) : text
throw new Error(`DumbCode Studio REST call failed (${response.status} ${response.statusText}): ${message}`)
}

return body
}

const executeStudioAction = async (action: McpActionName, args: Record<string, unknown> = {}, timeoutMs?: number) => {
const body = timeoutMs === undefined ? args : { ...args, timeoutMs }
const response = await requestStudio(`/api/mcp/action/${encodeURIComponent(action)}`, {
method: "POST",
body: JSON.stringify(body),
})
if (typeof response === "object" && response !== null && "result" in response) {
return (response as { result: unknown }).result
}
return response
}

const toolText = (value: unknown) => ({
content: [{ type: "text" as const, text: value === undefined ? "undefined" : (typeof value === "string" ? value : JSON.stringify(value, null, 2)) }],
})

const timeoutSchema = z.number().int().positive().max(600000).optional().describe("Optional action timeout in milliseconds")
const argsSchema = z.record(z.unknown()).optional().describe("Backward-compatible action-specific arguments object")

const directActionInputSchema = (action: McpActionName) => {
const shape: Record<string, z.ZodTypeAny> = {
args: argsSchema,
timeoutMs: timeoutSchema,
}
for (const [name, description] of Object.entries(MCP_ACTION_SCHEMA[action])) {
shape[name] = z.unknown().optional().describe(description)
}
return z.object(shape).passthrough()
}

const normalizeActionArgs = (input: Record<string, unknown>) => {
const { args, timeoutMs: _timeoutMs, ...directArgs } = input
const baseArgs = args !== undefined && typeof args === "object" && args !== null && !Array.isArray(args)
? args as Record<string, unknown>
: {}
return { ...baseArgs, ...directArgs }
}

const server = new McpServer({
name: "dumbcode-studio",
version: "1.0.0",
})

server.registerTool(
"dcs_status",
{
description: "Check the DumbCode Studio REST bridge, connected browser clients, queue depth, and available actions.",
inputSchema: {},
},
async () => toolText(await requestStudio("/api/mcp/health"))
)

server.registerTool(
"dcs_action",
{
description: "Run any DumbCode Studio action. Call dcs_get_action_schema first when choosing argument names.",
inputSchema: {
action: z.enum([...MCP_ACTIONS] as [McpActionName, ...McpActionName[]]).describe("DumbCode Studio action name"),
args: z.record(z.unknown()).optional().describe("Action-specific arguments. See dcs_get_action_schema."),
timeoutMs: timeoutSchema,
},
},
async ({ action, args, timeoutMs }) => toolText(await executeStudioAction(action, args ?? {}, timeoutMs))
)

for (const action of MCP_ACTIONS) {
server.registerTool(
`dcs_${action}`,
{
description: `${MCP_ACTION_DESCRIPTIONS[action]} Pass fields directly, or pass { args: ... } for compatibility.`,
inputSchema: directActionInputSchema(action),
},
async input => toolText(await executeStudioAction(action, normalizeActionArgs(input), input.timeoutMs as number | undefined))
)
}

const main = async () => {
const transport = new StdioServerTransport()
await server.connect(transport)
}

main().catch(error => {
const message = error instanceof Error ? error.stack ?? error.message : String(error)
console.error(message)
process.exit(1)
})
18 changes: 18 additions & 0 deletions apps/mcp-server/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}
Loading