@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/dist/index.cjs +1 -1
- package/dist/index.d.cts +519 -106
- package/dist/index.d.mts +520 -107
- package/dist/index.mjs +1 -1
- package/dist/{stdio-48jCJzS3.mjs → stdio-HOHWH0x4.mjs} +460 -122
- package/dist/{stdio-9ewjcCag.cjs → stdio-N6Yxacus.cjs} +482 -121
- package/dist/stdio.cjs +1 -1
- package/dist/stdio.mjs +1 -1
- package/package.json +7 -4
- package/src/server.ts +5 -1
- package/src/stdio.ts +6 -1
- package/src/tool-definitions.ts +105 -10
- package/src/tool-handler.ts +52 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfairygui/mcp",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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/
|
|
67
|
-
"@openfairygui/
|
|
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
|
|
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
|
|
package/src/tool-definitions.ts
CHANGED
|
@@ -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.
|
|
66
|
-
ok: z.
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(),
|
package/src/tool-handler.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
-
import
|
|
3
|
-
|
|
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
|
|
39
|
+
text,
|
|
31
40
|
},
|
|
32
41
|
],
|
|
33
42
|
structuredContent: {
|
|
34
|
-
backendResult:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
}
|