@openfairygui/mcp 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfairygui/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "FairyGUI Headless Authoring SDK - MCP server adapter for the backend runtime.",
5
5
  "author": "OpenFairyGUI Contributors",
6
6
  "license": "MIT",
@@ -13,6 +13,9 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/OpenFairyGUI/OpenFairyGUI/issues"
15
15
  },
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
16
19
  "type": "module",
17
20
  "sideEffects": false,
18
21
  "main": "./dist/index.cjs",
@@ -58,13 +61,13 @@
58
61
  "dependencies": {
59
62
  "@modelcontextprotocol/sdk": "^1.29.0",
60
63
  "zod": "^4.3.6",
61
- "@openfairygui/backend": "0.3.0"
64
+ "@openfairygui/backend": "0.3.1"
62
65
  },
63
66
  "devDependencies": {
64
67
  "ava": "^7.0.0",
65
68
  "tsx": "^4.0.0",
66
- "@openfairygui/test-utils": "0.2.0-alpha.10",
67
- "@openfairygui/core": "0.3.0"
69
+ "@openfairygui/core": "0.3.1",
70
+ "@openfairygui/test-utils": "0.3.0"
68
71
  },
69
72
  "ava": {
70
73
  "extensions": {
package/src/server.ts CHANGED
@@ -35,12 +35,16 @@ const PACKAGE_VERSION = readPackageVersion();
35
35
 
36
36
  export interface CreateOpenFairyGuiMcpServerOptions {
37
37
  runtime?: OpenFairyGuiBackendRuntime;
38
+ /** Filesystem roots exposed by the default Node backend runtime. Defaults to process.cwd(). */
39
+ allowedProjectRoots?: readonly string[];
38
40
  name?: string;
39
41
  version?: string;
40
42
  }
41
43
 
42
44
  export function createOpenFairyGuiMcpServer(options: CreateOpenFairyGuiMcpServerOptions = {}): McpServer {
43
- const runtime = options.runtime ?? createNodeBackendRuntime();
45
+ const runtime = options.runtime ?? createNodeBackendRuntime({
46
+ allowedProjectRoots: options.allowedProjectRoots ?? [process.cwd()],
47
+ });
44
48
  const server = new McpServer({
45
49
  name: options.name ?? 'openfairygui-mcp',
46
50
  version: options.version ?? PACKAGE_VERSION,
package/src/stdio.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import path from 'node:path';
2
3
  import { pathToFileURL } from 'node:url';
3
4
  import { createOpenFairyGuiMcpServer } from './server.js';
4
5
 
5
6
  export async function connectOpenFairyGuiMcpStdio(): Promise<void> {
6
- const server = createOpenFairyGuiMcpServer();
7
+ const configuredRoots = process.env.OPENFAIRYGUI_ALLOWED_PROJECT_ROOTS
8
+ ?.split(path.delimiter)
9
+ .map((value) => value.trim())
10
+ .filter(Boolean);
11
+ const server = createOpenFairyGuiMcpServer({ allowedProjectRoots: configuredRoots });
7
12
  await server.connect(new StdioServerTransport());
8
13
  }
9
14
 
@@ -60,14 +60,109 @@ const sessionId = z.string().min(1);
60
60
  const jobId = z.string().min(1);
61
61
  const expectedRevision = z.number().int().nonnegative();
62
62
  const limit = z.number().int().nonnegative().optional();
63
+ const identifier = z.string().min(1).max(256);
64
+ export function isOpenFairyGuiMcpPayloadWithinBudget(root: unknown): boolean {
65
+ const pending: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
66
+ let nodes = 0;
67
+ while (pending.length > 0) {
68
+ const { value, depth } = pending.pop()!;
69
+ nodes += 1;
70
+ if (nodes > 100_000 || depth > 32) return false;
71
+ if (value === null || typeof value === 'boolean') continue;
72
+ if (typeof value === 'number') {
73
+ if (!Number.isFinite(value)) return false;
74
+ continue;
75
+ }
76
+ if (typeof value === 'string') {
77
+ if (value.length > 1_000_000) return false;
78
+ continue;
79
+ }
80
+ if (value instanceof Uint8Array) {
81
+ if (value.byteLength > 8 * 1024 * 1024) return false;
82
+ continue;
83
+ }
84
+ if (Array.isArray(value)) {
85
+ if (value.length > 10_000) return false;
86
+ for (const child of value) pending.push({ value: child, depth: depth + 1 });
87
+ continue;
88
+ }
89
+ if (typeof value !== 'object') return false;
90
+ const entries = Object.entries(value);
91
+ if (entries.length > 10_000 || entries.some(([key]) => key.length > 256)) return false;
92
+ for (const [, child] of entries) pending.push({ value: child, depth: depth + 1 });
93
+ }
94
+ return true;
95
+ }
96
+ const boundedPayload = z.json();
97
+ const bytes = z.array(z.number().int().min(0).max(255)).max(8 * 1024 * 1024);
98
+ const packageSelector = z.object({ packageId: identifier });
99
+ const resourceSelector = z.object({ packageId: identifier, resourceId: identifier });
100
+ const componentSelector = z.object({ packageId: identifier, componentResourceId: identifier });
101
+ const displayNodeSelector = componentSelector.extend({ displayNodeId: identifier });
102
+ const controllerSelector = componentSelector.extend({ controllerName: identifier });
103
+ const transitionSelector = componentSelector.extend({ transitionName: identifier });
104
+ const folderSelector = packageSelector.extend({ branch: z.string().max(256).optional(), path: z.string().min(1).max(4096) });
105
+ const operationBase = { opId: identifier.optional() };
106
+ const operation = z.discriminatedUnion('kind', [
107
+ z.object({ ...operationBase, kind: z.literal('updateProjectSettings'), settings: boundedPayload }),
108
+ z.object({ ...operationBase, kind: z.literal('updatePackageSettings'), selector: packageSelector, settings: boundedPayload }),
109
+ z.object({ ...operationBase, kind: z.literal('renameResource'), selector: resourceSelector, newName: identifier }),
110
+ z.object({ ...operationBase, kind: z.literal('moveResource'), selector: resourceSelector, toPath: z.string().max(4096) }),
111
+ z.object({ ...operationBase, kind: z.literal('setResourceFavorite'), selector: resourceSelector, favorite: z.boolean() }),
112
+ z.object({ ...operationBase, kind: z.literal('setResourceFolderFavorite'), selector: folderSelector, favorite: z.boolean() }),
113
+ z.object({ ...operationBase, kind: z.literal('setResourceFolderAtlas'), selector: folderSelector, atlas: z.string().max(32) }),
114
+ z.object({ ...operationBase, kind: z.literal('setResourceExported'), selector: resourceSelector, exported: z.boolean() }),
115
+ z.object({ ...operationBase, kind: z.literal('addResourceFolder'), selector: packageSelector, path: z.string().max(4096), branch: z.string().max(256).optional(), favorite: z.boolean().optional(), atlas: z.string().max(32).optional() }),
116
+ z.object({ ...operationBase, kind: z.literal('renameResourceFolder'), selector: folderSelector, newName: identifier }),
117
+ z.object({ ...operationBase, kind: z.literal('moveResourceFolder'), selector: folderSelector, toPath: z.string().max(4096) }),
118
+ z.object({ ...operationBase, kind: z.literal('removeResourceFolder'), selector: folderSelector }),
119
+ z.object({ ...operationBase, kind: z.literal('setImageResourceProps'), selector: resourceSelector, props: boundedPayload }),
120
+ z.object({ ...operationBase, kind: z.literal('addResource'), selector: packageSelector, resource: boundedPayload, atIndex: z.number().int().nonnegative().optional() }),
121
+ z.object({ ...operationBase, kind: z.literal('addBranch'), branch: identifier }),
122
+ z.object({ ...operationBase, kind: z.literal('renameBranch'), selector: z.object({ branch: identifier }), newName: identifier }),
123
+ z.object({ ...operationBase, kind: z.literal('removeBranch'), selector: z.object({ branch: identifier }) }),
124
+ z.object({ ...operationBase, kind: z.literal('addPackage'), package: boundedPayload, atIndex: z.number().int().nonnegative() }),
125
+ z.object({ ...operationBase, kind: z.literal('renamePackage'), selector: packageSelector, newName: identifier }),
126
+ z.object({ ...operationBase, kind: z.literal('removePackage'), selector: packageSelector }),
127
+ z.object({ ...operationBase, kind: z.literal('addComponent'), selector: packageSelector, component: boundedPayload, atIndex: z.number().int().nonnegative() }),
128
+ z.object({ ...operationBase, kind: z.literal('removeComponent'), selector: componentSelector }),
129
+ z.object({ ...operationBase, kind: z.literal('moveComponent'), selector: componentSelector, toPackageId: identifier, toIndex: z.number().int().nonnegative() }),
130
+ z.object({ ...operationBase, kind: z.literal('replaceResourceBytes'), selector: resourceSelector, sourceBytes: bytes }),
131
+ z.object({ ...operationBase, kind: z.literal('removeResource'), selector: resourceSelector }),
132
+ z.object({ ...operationBase, kind: z.literal('setDisplayNodeProps'), selector: displayNodeSelector, props: boundedPayload }),
133
+ z.object({ ...operationBase, kind: z.literal('setComponentProps'), selector: componentSelector, props: boundedPayload }),
134
+ z.object({ ...operationBase, kind: z.literal('attachDisplayNode'), selector: componentSelector, atIndex: z.number().int().nonnegative(), node: boundedPayload }),
135
+ z.object({ ...operationBase, kind: z.literal('detachDisplayNode'), selector: displayNodeSelector }),
136
+ ...(['addController', 'updateController'] as const).map((kind) => z.object({ ...operationBase, kind: z.literal(kind), selector: controllerSelector, controller: boundedPayload })),
137
+ z.object({ ...operationBase, kind: z.literal('removeController'), selector: controllerSelector }),
138
+ ...(['addTransition', 'updateTransition'] as const).map((kind) => z.object({ ...operationBase, kind: z.literal(kind), selector: transitionSelector, transition: boundedPayload })),
139
+ z.object({ ...operationBase, kind: z.literal('removeTransition'), selector: transitionSelector }),
140
+ ...(['addLookGear', 'updateLookGear', 'addGear', 'updateGear'] as const).map((kind) => z.object({ ...operationBase, kind: z.literal(kind), selector: displayNodeSelector.extend({ kind: identifier, controllerName: identifier }), gear: boundedPayload })),
141
+ ...(['removeLookGear', 'removeGear'] as const).map((kind) => z.object({ ...operationBase, kind: z.literal(kind), selector: displayNodeSelector.extend({ kind: identifier, controllerName: identifier }) })),
142
+ ]);
143
+ const project = z.object({
144
+ projectId: identifier,
145
+ projectType: z.number().int(),
146
+ version: z.string().max(256),
147
+ branches: z.array(z.string().max(256)).max(256),
148
+ settings: boundedPayload,
149
+ packages: z.array(z.object({
150
+ id: identifier,
151
+ name: identifier,
152
+ compressPNG: z.boolean().nullable(),
153
+ jpegQuality: z.number().finite().nullable(),
154
+ publish: boundedPayload.nullable(),
155
+ branchNames: z.array(z.string().max(256)).max(256),
156
+ folders: z.array(boundedPayload).max(10_000),
157
+ resources: z.array(boundedPayload).max(100_000),
158
+ })).max(1_000),
159
+ });
63
160
 
64
161
  export const OPENFAIRYGUI_BACKEND_TOOL_OUTPUT_SCHEMA = z.object({
65
- backendResult: z.object({
66
- ok: z.boolean(),
67
- data: z.unknown().optional(),
68
- error: z.unknown().optional(),
69
- meta: z.unknown().optional(),
70
- }).passthrough(),
162
+ backendResult: z.discriminatedUnion('ok', [
163
+ z.object({ ok: z.literal(true), data: boundedPayload, meta: boundedPayload }),
164
+ z.object({ ok: z.literal(false), error: z.object({ code: identifier, message: z.string().max(1_000_000) }).passthrough(), meta: boundedPayload, session: boundedPayload.optional() }),
165
+ ]),
71
166
  });
72
167
 
73
168
  export const OPENFAIRYGUI_BACKEND_TOOL_DEFINITIONS = [
@@ -97,7 +192,7 @@ export const OPENFAIRYGUI_BACKEND_TOOL_DEFINITIONS = [
97
192
  title: 'Open Project Session',
98
193
  description: 'Open a browser-safe backend session from an already loaded UAM project without filesystem access.',
99
194
  inputSchema: z.object({
100
- project: z.unknown(),
195
+ project,
101
196
  sessionId: z.string().min(1).optional(),
102
197
  canonicalProjectPath: z.string().min(1).optional(),
103
198
  canonicalPathKey: z.string().min(1).optional(),
@@ -136,11 +231,11 @@ export const OPENFAIRYGUI_BACKEND_TOOL_DEFINITIONS = [
136
231
  name: 'openfairygui_backend_apply_transaction',
137
232
  backendMethod: 'applyTransaction',
138
233
  title: 'Apply UAM Transaction',
139
- description: 'Apply a backend revision-checked UAM operation batch without redefining selector or operation grammar.',
234
+ description: 'Apply a bounded, revision-checked UAM operation batch using the Core transaction discriminants.',
140
235
  inputSchema: z.object({
141
236
  sessionId,
142
237
  expectedRevision,
143
- operations: z.array(z.unknown()),
238
+ operations: z.array(operation).min(1).max(1_000),
144
239
  }),
145
240
  outputSchema: OPENFAIRYGUI_BACKEND_TOOL_OUTPUT_SCHEMA,
146
241
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
@@ -149,7 +244,7 @@ export const OPENFAIRYGUI_BACKEND_TOOL_DEFINITIONS = [
149
244
  name: 'openfairygui_backend_save_session',
150
245
  backendMethod: 'saveSession',
151
246
  title: 'Save Backend Session',
152
- description: 'Write the current backend session back through the backend coordinated non-atomic save path.',
247
+ description: 'Write the current backend session through its coordinated save path; Node uses an atomic staged directory swap.',
153
248
  inputSchema: z.object({
154
249
  sessionId,
155
250
  expectedRevision: expectedRevision.optional(),
@@ -1,6 +1,13 @@
1
1
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
- import type { BackendRuntime } from '@openfairygui/backend';
3
- import type { OpenFairyGuiBackendToolName } from './tool-definitions.js';
2
+ import {
3
+ BACKEND_CAPABILITY_SCHEMA_VERSION,
4
+ BACKEND_CONTRACT_VERSION,
5
+ type BackendRuntime,
6
+ } from '@openfairygui/backend';
7
+ import {
8
+ isOpenFairyGuiMcpPayloadWithinBudget,
9
+ type OpenFairyGuiBackendToolName,
10
+ } from './tool-definitions.js';
4
11
 
5
12
  export type OpenFairyGuiBackendRuntime = Pick<
6
13
  BackendRuntime,
@@ -23,15 +30,17 @@ export type OpenFairyGuiBackendRuntime = Pick<
23
30
  >;
24
31
 
25
32
  function jsonResult(payload: unknown, isError = false): CallToolResult {
33
+ const text = JSON.stringify(payload, null, 2);
34
+ const wirePayload = JSON.parse(text) as unknown;
26
35
  return {
27
36
  content: [
28
37
  {
29
38
  type: 'text',
30
- text: JSON.stringify(payload, null, 2),
39
+ text,
31
40
  },
32
41
  ],
33
42
  structuredContent: {
34
- backendResult: payload,
43
+ backendResult: wirePayload,
35
44
  },
36
45
  isError,
37
46
  };
@@ -44,13 +53,37 @@ function isBackendFailure(value: unknown): boolean {
44
53
  && (value as { ok?: unknown }).ok === false;
45
54
  }
46
55
 
56
+ function unhandledBackendFailure(startedAt: number): unknown {
57
+ return {
58
+ ok: false,
59
+ meta: {
60
+ requestId: crypto.randomUUID(),
61
+ durationMs: Math.max(0, Date.now() - startedAt),
62
+ warnings: [],
63
+ diagnostics: [],
64
+ stage: 'runtime',
65
+ contractVersion: BACKEND_CONTRACT_VERSION,
66
+ capabilitySchemaVersion: BACKEND_CAPABILITY_SCHEMA_VERSION,
67
+ },
68
+ error: {
69
+ code: 'backend_unhandled_error',
70
+ message: 'Backend tool execution failed.',
71
+ },
72
+ };
73
+ }
74
+
47
75
  export async function callOpenFairyGuiBackendTool(
48
76
  runtime: OpenFairyGuiBackendRuntime,
49
77
  name: OpenFairyGuiBackendToolName,
50
78
  input: Record<string, unknown>,
51
79
  ): Promise<CallToolResult> {
80
+ if (!isOpenFairyGuiMcpPayloadWithinBudget(input)) {
81
+ throw new RangeError('MCP input exceeds the depth, node, key, string, or byte budget.');
82
+ }
83
+ const startedAt = Date.now();
52
84
  let result: unknown;
53
- switch (name) {
85
+ try {
86
+ switch (name) {
54
87
  case 'openfairygui_backend_get_capabilities':
55
88
  result = runtime.getCapabilities();
56
89
  break;
@@ -82,13 +115,19 @@ export async function callOpenFairyGuiBackendTool(
82
115
  sessionId: String(input.sessionId),
83
116
  });
84
117
  break;
85
- case 'openfairygui_backend_apply_transaction':
118
+ case 'openfairygui_backend_apply_transaction': {
119
+ const operations = (input.operations as Parameters<BackendRuntime['applyTransaction']>[0]['operations']).map(
120
+ (operation) => operation.kind === 'replaceResourceBytes'
121
+ ? { ...operation, sourceBytes: new Uint8Array(operation.sourceBytes) }
122
+ : operation,
123
+ );
86
124
  result = await runtime.applyTransaction({
87
125
  sessionId: String(input.sessionId),
88
126
  expectedRevision: Number(input.expectedRevision),
89
- operations: input.operations as Parameters<BackendRuntime['applyTransaction']>[0]['operations'],
127
+ operations,
90
128
  });
91
129
  break;
130
+ }
92
131
  case 'openfairygui_backend_save_session':
93
132
  result = await runtime.saveSession({
94
133
  sessionId: String(input.sessionId),
@@ -149,10 +188,13 @@ export async function callOpenFairyGuiBackendTool(
149
188
  reason: input.reason as Parameters<BackendRuntime['refreshCache']>[0]['reason'],
150
189
  });
151
190
  break;
152
- default: {
153
- const exhaustive: never = name;
154
- throw new Error(`Unknown OpenFairyGUI backend MCP tool: ${exhaustive}`);
191
+ default: {
192
+ const exhaustive: never = name;
193
+ throw new Error(`Unknown OpenFairyGUI backend MCP tool: ${exhaustive}`);
194
+ }
155
195
  }
196
+ } catch {
197
+ return jsonResult(unhandledBackendFailure(startedAt), true);
156
198
  }
157
199
  return jsonResult(result, isBackendFailure(result));
158
200
  }