@uemcp/server 0.3.0 → 0.4.0

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.
Files changed (3) hide show
  1. package/README.md +30 -0
  2. package/dist/index.mjs +488 -142
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -137,6 +137,36 @@ This is intended to let an MCP client resolve the canonical Unreal project ident
137
137
  - Spawns `clang-tidy` (server-configured via `CLANG_TIDY_PATH`, else `PATH`) over the translation units and returns structured findings (file, line, column, check, severity, message)
138
138
  - Report only — does not apply fixes
139
139
 
140
+ ## MCP Resources
141
+
142
+ Resources carry context a client can attach without spending a tool call and a model turn. Every one of them is served from in-memory state or the local filesystem: **a resource read never opens a bridge socket and never waits on the editor**, so an unreachable or absent editor produces a structured payload rather than a timeout.
143
+
144
+ All resources use the `uemcp://` URI scheme, are `application/json`, and are capped at 64 KB (`MAX_RESOURCE_PAYLOAD_BYTES`). A read that hits the cap drops entries from the end of the list and sets `truncated: true` while `totalCount` stays authoritative — the same convention the paged tools use. The authority segment (`registry`, `engines`, `workspace`) is the type discriminator.
145
+
146
+ | URI | Contents |
147
+ | --- | --- |
148
+ | `uemcp://registry` | Every registered Unreal Editor: `projectId`, `projectName`, `projectPath`, `port`, `pid`, `registeredAt`, `lastSeenAt`, plus `totalCount` and `truncated` |
149
+ | `uemcp://registry/{projectId}` | One registration, addressed by canonical ProjectID. Enumerable — the template's `list` callback returns the current registry, and `projectId` offers completions from it |
150
+ | `uemcp://engines` | Unreal installs found under `UE_INSTALLS_DIR`, ascending by version, each with `engineVersion`, `engineRoot`, and `unrealBuildToolResolved`. Source builds and GUID engine associations are excluded, matching `resolveEngine` |
151
+ | `uemcp://workspace/project` | The Unreal project identity of the server's working directory, resolved from `Config/DefaultGame.ini` |
152
+
153
+ `uemcp://registry/{projectId}` reports `found: false` with a `reason` for an unknown or malformed ProjectID, and `uemcp://engines` / `uemcp://workspace/project` report a `reason` when `UE_INSTALLS_DIR` is unset or the directory is not an Unreal project — none of them fail the read.
154
+
155
+ Registering or deregistering an editor over the HTTP API emits `notifications/resources/list_changed`. There is no `resources/subscribe`: the MCP SDK's `McpServer` does not implement it, so the list-changed notification is the only change signal.
156
+
157
+ ## MCP Prompts
158
+
159
+ Prompts ship the workflows this server knows about as discoverable entry points for any MCP client, not just ones that can read the repo's `.agents/skills/` runbooks. They are compiled into the bundle, reference only tools this server registers, and assume no shell, no checkout, and no particular client.
160
+
161
+ | Prompt | Arguments | What it drives |
162
+ | --- | --- | --- |
163
+ | `diagnose_crash` | `projectId` | Registration check, `get_crash_callstack`, `get_editor_state`, then the output log around the crash |
164
+ | `diagnose_bridge_disconnect` | `projectPath` (optional) | Which leg of the bridge is broken: registry state, `get_project_id` cross-check, `ping_project`, plugin-side log evidence |
165
+ | `smoke_test_bridge` | `projectId` | Green-path proof: registration, `ping_project`, `get_editor_state`, and a full log subscribe-emit-drain cycle |
166
+ | `add_gameplay_feature` | `projectId`, `className`, `parentClass`, `moduleName` (optional) | `create_cpp_class` → `wait_for_job` → implement → `trigger_live_compile` → `run_automation_tests` → `save_dirty_packages` |
167
+
168
+ `diagnose_bridge_disconnect` and `smoke_test_bridge` are distilled from the same-named skills in `.agents/skills/`. Those files stay separate on purpose: they are maintainer runbooks that reference `just` recipes, `registry.json` paths, and pino module loggers, and the published package ships `dist` only, so they cannot be read at runtime. Each skill carries a pointer to its prompt counterpart so the two do not silently diverge.
169
+
140
170
  ## Configuration
141
171
 
142
172
  - Engine toolchain (`build_target`, `generate_project_files`, `clean_build`):
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import os, { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { z } from "zod";
@@ -2429,6 +2429,13 @@ var RegistryWriteQueue = class {
2429
2429
  function createHttpApi(options) {
2430
2430
  const app = Fastify({ logger: false });
2431
2431
  const registryWriteQueue = options.registryWriteQueue ?? new RegistryWriteQueue();
2432
+ const notifyRegistryChanged = () => {
2433
+ try {
2434
+ options.onRegistryChanged?.();
2435
+ } catch (error) {
2436
+ options.logger.warn({ error }, "Failed to notify listeners that the registry changed");
2437
+ }
2438
+ };
2432
2439
  app.setErrorHandler((error, _request, reply) => {
2433
2440
  if (error instanceof HttpError) {
2434
2441
  reply.code(error.statusCode).send({
@@ -2513,6 +2520,7 @@ function createHttpApi(options) {
2513
2520
  projectName: entry.projectName,
2514
2521
  projectPath: entry.projectPath
2515
2522
  }, "Upserted Unreal Editor project registration");
2523
+ notifyRegistryChanged();
2516
2524
  return {
2517
2525
  projectId: entry.projectId,
2518
2526
  stored: true
@@ -2543,6 +2551,7 @@ function createHttpApi(options) {
2543
2551
  existed,
2544
2552
  projectId
2545
2553
  }, "Removed Unreal Editor project registration");
2554
+ if (existed) notifyRegistryChanged();
2546
2555
  return {
2547
2556
  projectId,
2548
2557
  removed: true
@@ -2972,144 +2981,6 @@ function isLogLevel(value) {
2972
2981
  return value === "fatal" || value === "error" || value === "warn" || value === "info" || value === "debug" || value === "trace" || value === "silent";
2973
2982
  }
2974
2983
  //#endregion
2975
- //#region src/buildDiagnostics.ts
2976
- const CLANG_DIAGNOSTIC_PATTERN = /^(?<file>.+?):(?<line>\d+):(?:(?<column>\d+):)?\s+(?<severity>fatal error|error|warning):\s+(?<message>.*)$/u;
2977
- const MSVC_DIAGNOSTIC_PATTERN = /^(?<file>.+?)\((?<line>\d+)(?:,\d+)?\):\s+(?<severity>fatal error|error|warning)\s+(?<code>[A-Z]+\d+):\s+(?<message>.*)$/u;
2978
- const CLANG_TIDY_PATTERN = /^(?<file>.+?):(?<line>\d+):(?<column>\d+):\s+(?<severity>error|warning):\s+(?<message>.*?)(?:\s+\[(?<check>[^\]]+)\])?$/u;
2979
- const COOK_LINE_PREFIX_PATTERN = /^(?:\[[^\]]*\]\s*)*(?:[A-Za-z]+Helper:\s*[^:]*:\s*)?/u;
2980
- const COOK_DIAGNOSTIC_PATTERN = /^(?<category>[A-Za-z_][A-Za-z0-9_]*):\s+(?<severity>Error|Warning)(?::)?\s+(?<message>.+)$/u;
2981
- const UAT_DIAGNOSTIC_PATTERN = /^(?<severity>ERROR|WARNING):\s+(?<message>.+)$/u;
2982
- function diagnosticKey(diagnostic) {
2983
- return `${diagnostic.file ?? ""}|${diagnostic.line ?? ""}|${diagnostic.message}`;
2984
- }
2985
- function matchCookDiagnostic(rawLine) {
2986
- const line = rawLine.trim().replace(COOK_LINE_PREFIX_PATTERN, "").trim();
2987
- if (line.length === 0) return null;
2988
- const categoryMatch = COOK_DIAGNOSTIC_PATTERN.exec(line);
2989
- if (categoryMatch?.groups) {
2990
- const { category, message, severity } = categoryMatch.groups;
2991
- if (category === void 0 || message === void 0 || severity === void 0) return null;
2992
- return {
2993
- isError: severity === "Error",
2994
- message: `${category}: ${message.trim()}`
2995
- };
2996
- }
2997
- const uatMatch = UAT_DIAGNOSTIC_PATTERN.exec(line);
2998
- if (!uatMatch?.groups) return null;
2999
- const { message, severity } = uatMatch.groups;
3000
- if (message === void 0 || severity === void 0) return null;
3001
- return {
3002
- isError: severity === "ERROR",
3003
- message: message.trim()
3004
- };
3005
- }
3006
- var CookDiagnosticCollector = class {
3007
- maxEntriesPerSeverity;
3008
- errors = [];
3009
- warnings = [];
3010
- seenErrors = /* @__PURE__ */ new Set();
3011
- seenWarnings = /* @__PURE__ */ new Set();
3012
- pending = "";
3013
- truncatedFlag = false;
3014
- constructor(maxEntriesPerSeverity) {
3015
- this.maxEntriesPerSeverity = maxEntriesPerSeverity;
3016
- }
3017
- push(chunk) {
3018
- const lines = (this.pending + chunk).split(/\r?\n/u);
3019
- this.pending = lines.pop() ?? "";
3020
- for (const line of lines) this.pushLine(line);
3021
- }
3022
- flush() {
3023
- if (this.pending.length === 0) return;
3024
- const remaining = this.pending;
3025
- this.pending = "";
3026
- this.pushLine(remaining);
3027
- }
3028
- snapshot() {
3029
- return {
3030
- errorCount: this.errors.length,
3031
- errors: [...this.errors],
3032
- truncated: this.truncatedFlag,
3033
- warningCount: this.warnings.length,
3034
- warnings: [...this.warnings]
3035
- };
3036
- }
3037
- pushLine(rawLine) {
3038
- const matched = matchCookDiagnostic(rawLine);
3039
- if (!matched) return;
3040
- const diagnostic = { message: matched.message };
3041
- const target = matched.isError ? this.errors : this.warnings;
3042
- const seen = matched.isError ? this.seenErrors : this.seenWarnings;
3043
- const key = diagnosticKey(diagnostic);
3044
- if (seen.has(key)) return;
3045
- seen.add(key);
3046
- if (target.length >= this.maxEntriesPerSeverity) {
3047
- this.truncatedFlag = true;
3048
- return;
3049
- }
3050
- target.push(diagnostic);
3051
- }
3052
- };
3053
- function parseBuildDiagnostics(output) {
3054
- const errors = [];
3055
- const warnings = [];
3056
- const seenErrors = /* @__PURE__ */ new Set();
3057
- const seenWarnings = /* @__PURE__ */ new Set();
3058
- for (const rawLine of output.split(/\r?\n/u)) {
3059
- const line = rawLine.trim();
3060
- if (line.length === 0) continue;
3061
- const match = CLANG_DIAGNOSTIC_PATTERN.exec(line) ?? MSVC_DIAGNOSTIC_PATTERN.exec(line);
3062
- if (!match?.groups) continue;
3063
- const groups = match.groups;
3064
- const { file, line: lineText, message, severity } = groups;
3065
- if (file === void 0 || lineText === void 0 || message === void 0 || severity === void 0) continue;
3066
- const code = typeof groups.code === "string" ? `${groups.code}: ` : "";
3067
- const diagnostic = {
3068
- file,
3069
- line: Number.parseInt(lineText, 10),
3070
- message: `${code}${message}`.trim()
3071
- };
3072
- const isError = severity === "error" || severity === "fatal error";
3073
- const target = isError ? errors : warnings;
3074
- const seen = isError ? seenErrors : seenWarnings;
3075
- const key = diagnosticKey(diagnostic);
3076
- if (seen.has(key)) continue;
3077
- seen.add(key);
3078
- target.push(diagnostic);
3079
- }
3080
- return {
3081
- errorCount: errors.length,
3082
- errors,
3083
- warningCount: warnings.length,
3084
- warnings
3085
- };
3086
- }
3087
- function parseClangTidyFindings(output) {
3088
- const findings = [];
3089
- const seen = /* @__PURE__ */ new Set();
3090
- for (const rawLine of output.split(/\r?\n/u)) {
3091
- const match = CLANG_TIDY_PATTERN.exec(rawLine.trim());
3092
- if (!match?.groups) continue;
3093
- const groups = match.groups;
3094
- const { file, line, message } = groups;
3095
- if (file === void 0 || line === void 0 || message === void 0) continue;
3096
- const severity = groups.severity === "error" ? "error" : "warning";
3097
- const finding = {
3098
- check: groups.check ?? "unknown",
3099
- file,
3100
- line: Number.parseInt(line, 10),
3101
- message: message.trim(),
3102
- severity
3103
- };
3104
- if (typeof groups.column === "string") finding.column = Number.parseInt(groups.column, 10);
3105
- const key = `${finding.file}|${finding.line}|${finding.column ?? ""}|${finding.check}|${finding.message}`;
3106
- if (seen.has(key)) continue;
3107
- seen.add(key);
3108
- findings.push(finding);
3109
- }
3110
- return findings;
3111
- }
3112
- //#endregion
3113
2984
  //#region src/engineToolchain.ts
3114
2985
  const UNREAL_BUILD_CONFIGURATIONS = [
3115
2986
  "Debug",
@@ -3140,6 +3011,7 @@ function cookCommandletTargetPlatform(platform) {
3140
3011
  const DEFAULT_PROCESS_TIMEOUT_MS = 18e5;
3141
3012
  const MAX_PROCESS_TIMEOUT_MS = 72e5;
3142
3013
  const DEFAULT_PACKAGE_TIMEOUT_MS = 54e5;
3014
+ const ENGINE_INSTALL_DIRECTORY_PATTERN = /^UE_(\d+(?:\.\d+)*)$/u;
3143
3015
  async function defaultFileExists(filePath) {
3144
3016
  try {
3145
3017
  await access(filePath);
@@ -3357,6 +3229,47 @@ async function resolveEngine(options) {
3357
3229
  unrealBuildTool
3358
3230
  };
3359
3231
  }
3232
+ async function listEngineInstalls(context) {
3233
+ const ueInstallsDir = context.env.UE_INSTALLS_DIR?.trim();
3234
+ if (!ueInstallsDir) throw new EngineToolchainError("UE_INSTALLS_DIR is not set; define it in .env (see .env.example).");
3235
+ let entries;
3236
+ try {
3237
+ entries = await context.listDirectory(ueInstallsDir);
3238
+ } catch (error) {
3239
+ throw new EngineToolchainError(`Failed to read UE_INSTALLS_DIR ${ueInstallsDir}: ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : void 0 });
3240
+ }
3241
+ const installs = [];
3242
+ for (const entry of entries) {
3243
+ const engineVersion = ENGINE_INSTALL_DIRECTORY_PATTERN.exec(entry)?.[1];
3244
+ if (engineVersion === void 0) continue;
3245
+ const engineRoot = path.join(ueInstallsDir, entry);
3246
+ installs.push({
3247
+ engineRoot,
3248
+ engineVersion,
3249
+ unrealBuildToolResolved: await isUnrealBuildToolResolvable(engineRoot, context)
3250
+ });
3251
+ }
3252
+ return installs.sort((left, right) => compareEngineVersions(left.engineVersion, right.engineVersion));
3253
+ }
3254
+ async function isUnrealBuildToolResolvable(engineRoot, context) {
3255
+ try {
3256
+ await resolveUnrealBuildTool(engineRoot, context);
3257
+ return true;
3258
+ } catch (error) {
3259
+ if (error instanceof EngineToolchainError) return false;
3260
+ throw error;
3261
+ }
3262
+ }
3263
+ function compareEngineVersions(left, right) {
3264
+ const leftSegments = left.split(".");
3265
+ const rightSegments = right.split(".");
3266
+ const segmentCount = Math.max(leftSegments.length, rightSegments.length);
3267
+ for (let index = 0; index < segmentCount; index += 1) {
3268
+ const difference = Number(leftSegments[index] ?? 0) - Number(rightSegments[index] ?? 0);
3269
+ if (difference !== 0) return difference;
3270
+ }
3271
+ return 0;
3272
+ }
3360
3273
  async function resolveUnrealBuildTool(engineRoot, context) {
3361
3274
  if (context.platform === "win32") {
3362
3275
  const ubtExe = path.join(engineRoot, "Engine", "Binaries", "DotNET", "UnrealBuildTool", "UnrealBuildTool.exe");
@@ -3467,6 +3380,425 @@ function buildUnrealBuildToolInvocation(engine, extraArgs) {
3467
3380
  command: engine.unrealBuildTool.command
3468
3381
  };
3469
3382
  }
3383
+ const RESOURCE_MIME_TYPE = "application/json";
3384
+ const REGISTRY_RESOURCE_URI = "uemcp://registry";
3385
+ const REGISTRY_PROJECT_RESOURCE_URI_TEMPLATE = "uemcp://registry/{projectId}";
3386
+ const ENGINES_RESOURCE_URI = "uemcp://engines";
3387
+ const WORKSPACE_PROJECT_RESOURCE_URI = "uemcp://workspace/project";
3388
+ function readRegistryResource(dependencies) {
3389
+ const entries = dependencies.registry.snapshot();
3390
+ return clampResourceList(entries, (visibleEntries, truncated) => ({
3391
+ entries: visibleEntries,
3392
+ totalCount: entries.length,
3393
+ truncated
3394
+ }));
3395
+ }
3396
+ function readRegistryProjectResource(projectId, dependencies) {
3397
+ const parsedProjectId = projectIdSchema.safeParse(projectId);
3398
+ if (!parsedProjectId.success) return {
3399
+ entry: null,
3400
+ found: false,
3401
+ projectId,
3402
+ reason: "The requested projectId is not a canonical 32-character Unreal ProjectID.",
3403
+ truncated: false
3404
+ };
3405
+ const entry = dependencies.registry.getProject(parsedProjectId.data);
3406
+ if (!entry) return {
3407
+ entry: null,
3408
+ found: false,
3409
+ projectId: parsedProjectId.data,
3410
+ reason: "No Unreal Editor is registered with this server for that projectId.",
3411
+ truncated: false
3412
+ };
3413
+ return clampResourcePayload({
3414
+ entry,
3415
+ found: true,
3416
+ projectId: entry.projectId,
3417
+ truncated: false
3418
+ }, () => ({
3419
+ entry: null,
3420
+ found: true,
3421
+ projectId: parsedProjectId.data,
3422
+ reason: "The registry entry exceeded the resource payload budget.",
3423
+ truncated: true
3424
+ }));
3425
+ }
3426
+ async function readEnginesResource(dependencies) {
3427
+ const context = dependencies.engineToolchainContext ?? createEngineToolchainContext();
3428
+ const installsDirectory = context.env.UE_INSTALLS_DIR?.trim() ?? null;
3429
+ let engineInstalls;
3430
+ try {
3431
+ engineInstalls = await listEngineInstalls(context);
3432
+ } catch (error) {
3433
+ if (!(error instanceof EngineToolchainError)) throw error;
3434
+ dependencies.logger.warn({ error }, "Failed to enumerate installed Unreal engines for the engines resource");
3435
+ return {
3436
+ engineInstalls: [],
3437
+ installsDirectory: installsDirectory && installsDirectory.length > 0 ? installsDirectory : null,
3438
+ reason: error.message,
3439
+ totalCount: 0,
3440
+ truncated: false
3441
+ };
3442
+ }
3443
+ return clampResourceList(engineInstalls, (visibleInstalls, truncated) => ({
3444
+ engineInstalls: visibleInstalls,
3445
+ installsDirectory: installsDirectory && installsDirectory.length > 0 ? installsDirectory : null,
3446
+ totalCount: engineInstalls.length,
3447
+ truncated
3448
+ }));
3449
+ }
3450
+ async function readWorkspaceProjectResource(dependencies) {
3451
+ const workingDirectory = dependencies.workingDirectory ?? process.cwd();
3452
+ try {
3453
+ const resolved = await dependencies.projectIdResolver.resolveProjectIdFromProjectPath(workingDirectory);
3454
+ return clampResourcePayload({
3455
+ configPath: resolved.configPath,
3456
+ projectId: resolved.projectId,
3457
+ projectPath: resolved.projectPath,
3458
+ resolved: true,
3459
+ truncated: false,
3460
+ workingDirectory
3461
+ }, () => ({
3462
+ reason: "The resolved workspace project exceeded the resource payload budget.",
3463
+ resolved: false,
3464
+ truncated: true,
3465
+ workingDirectory
3466
+ }));
3467
+ } catch (error) {
3468
+ if (!(error instanceof ProjectIdResolutionError)) throw error;
3469
+ return {
3470
+ configPath: error.filePath,
3471
+ reason: error.reason,
3472
+ resolved: false,
3473
+ truncated: false,
3474
+ workingDirectory
3475
+ };
3476
+ }
3477
+ }
3478
+ function registerMcpResources(server, dependencies) {
3479
+ server.registerResource("registry", REGISTRY_RESOURCE_URI, {
3480
+ description: "Every Unreal Editor currently registered with this server, straight from the in-memory registry. Attach this instead of calling list_registered_projects when you only need to know which editors are live and which projectId to pass to the tools. Reads no files and never contacts an editor.",
3481
+ mimeType: RESOURCE_MIME_TYPE,
3482
+ title: "Registered Unreal Editors"
3483
+ }, (uri) => buildResourceContents(uri, readRegistryResource(dependencies)));
3484
+ server.registerResource("registry-project", new ResourceTemplate(REGISTRY_PROJECT_RESOURCE_URI_TEMPLATE, {
3485
+ complete: { projectId: (value) => completeRegisteredProjectId(value, dependencies) },
3486
+ list: () => listRegistryProjectResources(dependencies)
3487
+ }), {
3488
+ description: "The registration for a single Unreal Editor, addressed by canonical 32-character ProjectID. Reports found: false with a reason rather than failing when the projectId is unknown or malformed.",
3489
+ mimeType: RESOURCE_MIME_TYPE,
3490
+ title: "Registered Unreal Editor"
3491
+ }, (uri, variables) => buildResourceContents(uri, readRegistryProjectResource(readTemplateVariable(variables.projectId), dependencies)));
3492
+ server.registerResource("engines", ENGINES_RESOURCE_URI, {
3493
+ description: "Unreal Engine installs discovered under UE_INSTALLS_DIR, each with its version, engine root, and whether UnrealBuildTool resolved inside it. Source builds and GUID engine associations are excluded because the build tools cannot resolve them either.",
3494
+ mimeType: RESOURCE_MIME_TYPE,
3495
+ title: "Installed Unreal Engines"
3496
+ }, async (uri) => buildResourceContents(uri, await readEnginesResource(dependencies)));
3497
+ server.registerResource("workspace-project", WORKSPACE_PROJECT_RESOURCE_URI, {
3498
+ description: "The Unreal project identity of the server's working directory, resolved from Config/DefaultGame.ini. Reports resolved: false with a reason when the working directory is not an Unreal project.",
3499
+ mimeType: RESOURCE_MIME_TYPE,
3500
+ title: "Workspace Unreal Project"
3501
+ }, async (uri) => buildResourceContents(uri, await readWorkspaceProjectResource(dependencies)));
3502
+ }
3503
+ function listRegistryProjectResources(dependencies) {
3504
+ return { resources: dependencies.registry.snapshot().map((entry) => ({
3505
+ description: `Registration for ${entry.projectName} at ${entry.projectPath}`,
3506
+ mimeType: RESOURCE_MIME_TYPE,
3507
+ name: entry.projectId,
3508
+ title: entry.projectName,
3509
+ uri: buildRegistryProjectUri(entry.projectId)
3510
+ })) };
3511
+ }
3512
+ function completeRegisteredProjectId(value, dependencies) {
3513
+ const prefix = value.trim().toLowerCase();
3514
+ return dependencies.registry.snapshot().map((entry) => entry.projectId).filter((projectId) => projectId.startsWith(prefix));
3515
+ }
3516
+ function buildRegistryProjectUri(projectId) {
3517
+ return `${REGISTRY_RESOURCE_URI}/${projectId}`;
3518
+ }
3519
+ function buildResourceContents(uri, payload) {
3520
+ return { contents: [{
3521
+ mimeType: RESOURCE_MIME_TYPE,
3522
+ text: serializeResourcePayload(payload),
3523
+ uri: uri.href
3524
+ }] };
3525
+ }
3526
+ function readTemplateVariable(value) {
3527
+ if (Array.isArray(value)) return value[0] ?? "";
3528
+ return value ?? "";
3529
+ }
3530
+ function clampResourceList(items, build) {
3531
+ const fullPayload = build(items, false);
3532
+ if (measureResourcePayloadBytes(fullPayload) <= 65536) return fullPayload;
3533
+ let smallestRejected = items.length;
3534
+ let largestAccepted = 0;
3535
+ while (largestAccepted + 1 < smallestRejected) {
3536
+ const candidateCount = Math.floor((largestAccepted + smallestRejected) / 2);
3537
+ if (measureResourcePayloadBytes(build(items.slice(0, candidateCount), true)) <= 65536) {
3538
+ largestAccepted = candidateCount;
3539
+ continue;
3540
+ }
3541
+ smallestRejected = candidateCount;
3542
+ }
3543
+ return build(items.slice(0, largestAccepted), true);
3544
+ }
3545
+ function clampResourcePayload(payload, buildMinimal) {
3546
+ if (measureResourcePayloadBytes(payload) <= 65536) return payload;
3547
+ return buildMinimal();
3548
+ }
3549
+ function measureResourcePayloadBytes(payload) {
3550
+ return Buffer$1.byteLength(serializeResourcePayload(payload), "utf8");
3551
+ }
3552
+ function serializeResourcePayload(payload) {
3553
+ return JSON.stringify(payload, null, 2);
3554
+ }
3555
+ //#endregion
3556
+ //#region src/mcpPrompts.ts
3557
+ const projectIdPromptArgument = projectIdSchema.describe("Canonical 32-character Unreal ProjectID of the target editor. Read uemcp://registry to find one.");
3558
+ const diagnoseCrashPromptArgs = { projectId: projectIdPromptArgument };
3559
+ const diagnoseBridgeDisconnectPromptArgs = { projectPath: z.string().trim().min(1).optional().describe("Filesystem path of the Unreal project directory or .uproject, when it is known.") };
3560
+ const smokeTestBridgePromptArgs = { projectId: projectIdPromptArgument };
3561
+ const addGameplayFeaturePromptArgs = {
3562
+ className: bridgeCreateCppClassRequestSchema.shape.className.describe("Name of the C++ class to create, without the type prefix (for example PatrolComponent)."),
3563
+ moduleName: bridgeCreateCppClassRequestSchema.shape.moduleName.describe("Target module for the new class. Omitted means the project’s primary game module."),
3564
+ parentClass: bridgeCreateCppClassRequestSchema.shape.parentClass.describe("Parent class to derive from (for example Actor, ActorComponent, or a project class)."),
3565
+ projectId: projectIdPromptArgument
3566
+ };
3567
+ function buildDiagnoseCrashPrompt(args) {
3568
+ return buildPromptResult("Investigate the most recent Unreal Editor crash for a registered project.", [
3569
+ `Investigate the most recent crash for Unreal project ${args.projectId}.`,
3570
+ "",
3571
+ "Work in this order and stop as soon as the cause is established:",
3572
+ `1. Read ${buildRegistryProjectUri(args.projectId)}, then call ping_project. A registration that is present but unreachable is the signature of a crash: deregistration only runs on a clean shutdown, so a killed process leaves its entry behind. An absent entry proves nothing on its own — the editor may never have registered, or a prior health check may already have evicted it.`,
3573
+ "2. Call get_crash_callstack for the project. Read the newest record first. Note the crash type, the assert or exception text, and the topmost project frames — engine frames below them are usually the victim, not the cause.",
3574
+ "3. Call get_editor_state to see what the editor was doing (PIE state, loaded level, current selection). A crash during PIE and a crash while idle have different suspects.",
3575
+ "4. Call stream_output_log with maxEvents around 50 and minVerbosity of warning. The first call establishes the subscription and returns no events; call it again with the returned cursor to drain. Look for the warnings immediately preceding the crash.",
3576
+ "5. If the callstack names a Blueprint, call get_blueprint_compile_errors for it; if it names a C++ symbol, call get_compile_status to check whether the running binary matches the current source.",
3577
+ "",
3578
+ "Report the crash type, the failing frame, the evidence that supports it, and the smallest change that would address it. Say so plainly if the callstack is inconclusive rather than guessing."
3579
+ ].join("\n"));
3580
+ }
3581
+ function buildDiagnoseBridgeDisconnectPrompt(args) {
3582
+ const projectPathStep = args.projectPath ? `2. Call get_project_id with projectPath ${args.projectPath} to get the canonical ProjectID, then compare it to the registry entries from step 1. A mismatch means the editor and this server are reading different Config/DefaultGame.ini files.` : "2. Ask for the project path if you need one, then call get_project_id with it and compare the result to the registry entries from step 1. A mismatch means the editor and this server are reading different Config/DefaultGame.ini files.";
3583
+ return buildPromptResult("Diagnose why this server cannot reach a running Unreal Editor over the MCPBridge.", [
3584
+ "The MCP server cannot reach a running Unreal Editor. Find which leg of the connection is broken before changing anything.",
3585
+ "",
3586
+ "The two processes meet in two places: the editor registers itself over this server’s HTTP API, and this server opens a TCP bridge socket to the port the editor recorded in that registration.",
3587
+ "",
3588
+ `1. Read ${REGISTRY_RESOURCE_URI}. No entry at all means registration never landed or was evicted. An entry with a stale lastSeenAt means registration landed but health checks are failing.`,
3589
+ projectPathStep,
3590
+ "3. Call ping_project with the registered projectId. reachable: false with registrationRemoved: true means the editor’s TCP listener is gone — the editor died, or the plugin lost its port. Note that this call evicts a stale registration, so read the registry resource before calling it, not after.",
3591
+ "4. If the registration looks healthy but one specific tool fails, call stream_output_log and drain with the returned cursor. Plugin-side faults are logged under the LogMCPBridgeRuntime category: failures resolving the ProjectID, failures binding the bridge socket, and rejected registrations all name themselves there.",
3592
+ "",
3593
+ "Common causes, in rough order of likelihood: the editor was closed or crashed and the registration is stale; the editor is open on a different project than the one being asked about; the project directory was copied, so two directories share one ProjectID and fight over one registration; the plugin is pointed at a different server address.",
3594
+ "",
3595
+ "Report which leg is broken and the single smallest action that fixes it. Do not propose code changes until the diagnosis is complete — this failure is usually configuration, not a bug."
3596
+ ].join("\n"));
3597
+ }
3598
+ function buildSmokeTestBridgePrompt(args) {
3599
+ return buildPromptResult("Prove the MCPBridge works end to end for a registered project.", [
3600
+ `Verify the MCPBridge is healthy for Unreal project ${args.projectId}. This is green-path verification, not fault diagnosis.`,
3601
+ "",
3602
+ `1. Read ${buildRegistryProjectUri(args.projectId)}. Expect a registration whose lastSeenAt is recent and whose port is in the 7000-7100 range.`,
3603
+ "2. Call ping_project. Expect reachable: true, registrationRemoved: false, and attemptsUsed: 1.",
3604
+ "3. Call get_editor_state. Expect the loaded level and PIE state of the editor actually open in front of the user.",
3605
+ "4. Exercise log streaming, which is the one path that covers subscribe, emit, drain, and unsubscribe together:",
3606
+ " a. Call stream_output_log with categoryAllow of [\"LogTemp\"], textContains of \"smoke-bridge-output\", and maxEvents of 10. The first call returns an empty events array and a cursor. That is correct — the subscription has nothing buffered yet.",
3607
+ " b. Call exec_console_command with the command: log LogTemp Warning smoke-bridge-output ping",
3608
+ " c. Call stream_output_log again with the same arguments plus the cursor from step 4a. Expect at least one event whose message contains smoke-bridge-output, and a nextCursor that has advanced.",
3609
+ "",
3610
+ "Report each step as pass or fail with the value that proves it. If any step fails, stop and say which one — the diagnose_bridge_disconnect prompt takes it from there. Treat an empty first drain as expected, never as a failure."
3611
+ ].join("\n"));
3612
+ }
3613
+ function buildAddGameplayFeaturePrompt(args) {
3614
+ const moduleClause = args.moduleName ? ` in module ${args.moduleName}` : "";
3615
+ return buildPromptResult("Add a C++ gameplay class to a live Unreal project and take it through compile and test.", [
3616
+ `Add a C++ class ${args.className} deriving from ${args.parentClass}${moduleClause} to Unreal project ${args.projectId}, then get it compiling and tested.`,
3617
+ "",
3618
+ "Every step here is asynchronous. Each start returns a job id; do not assume success from the start call alone.",
3619
+ "",
3620
+ `1. Call create_cpp_class with className ${args.className}, parentClass ${args.parentClass}${args.moduleName ? `, moduleName ${args.moduleName}` : ""}. It returns a createClassJobId immediately.`,
3621
+ "2. Wait for it with wait_for_job using kind create_class, or poll get_create_class_status. Do not edit the generated files until it reports completion — the editor is still writing them.",
3622
+ "3. Read the generated header and source, then write the actual implementation. A generated class compiles but does nothing.",
3623
+ "4. Call trigger_live_compile, then wait_for_job with kind compile. On failure, read the diagnostics from get_compile_status, fix the source, and compile again. Do not proceed past a failing compile.",
3624
+ "5. Call list_automation_tests to find the tests covering this area, then run_automation_tests, then wait_for_job with kind test_run. Report which tests ran and which failed.",
3625
+ "6. Call save_dirty_packages once the work is done so nothing is left unsaved in the editor.",
3626
+ "",
3627
+ "If the project has no automation tests covering the new class, say so rather than reporting the step as passed."
3628
+ ].join("\n"));
3629
+ }
3630
+ function registerMcpPrompts(server) {
3631
+ server.registerPrompt("diagnose_crash", {
3632
+ argsSchema: diagnoseCrashPromptArgs,
3633
+ description: "Walk the most recent Unreal Editor crash for a project back to its cause using the crash callstack, editor state, and output log.",
3634
+ title: "Diagnose Unreal Crash"
3635
+ }, (args) => buildDiagnoseCrashPrompt(args));
3636
+ server.registerPrompt("diagnose_bridge_disconnect", {
3637
+ argsSchema: diagnoseBridgeDisconnectPromptArgs,
3638
+ description: "Find which leg of the MCPBridge is broken when this server cannot reach a running Unreal Editor — missing registration, ProjectID mismatch, dead listener, or a plugin-side failure.",
3639
+ title: "Diagnose Bridge Disconnect"
3640
+ }, (args) => buildDiagnoseBridgeDisconnectPrompt(args));
3641
+ server.registerPrompt("smoke_test_bridge", {
3642
+ argsSchema: smokeTestBridgePromptArgs,
3643
+ description: "Prove the MCPBridge round-trips end to end for a project: registration, ping, editor state, and a full log subscribe-emit-drain cycle.",
3644
+ title: "Smoke-Test the Bridge"
3645
+ }, (args) => buildSmokeTestBridgePrompt(args));
3646
+ server.registerPrompt("add_gameplay_feature", {
3647
+ argsSchema: addGameplayFeaturePromptArgs,
3648
+ description: "Add a C++ gameplay class to a live Unreal project and drive it through class creation, implementation, live compile, and automation tests.",
3649
+ title: "Add Gameplay Feature"
3650
+ }, (args) => buildAddGameplayFeaturePrompt(args));
3651
+ }
3652
+ function buildPromptResult(description, text) {
3653
+ return {
3654
+ description,
3655
+ messages: [{
3656
+ content: {
3657
+ text,
3658
+ type: "text"
3659
+ },
3660
+ role: "user"
3661
+ }]
3662
+ };
3663
+ }
3664
+ //#endregion
3665
+ //#region src/buildDiagnostics.ts
3666
+ const CLANG_DIAGNOSTIC_PATTERN = /^(?<file>.+?):(?<line>\d+):(?:(?<column>\d+):)?\s+(?<severity>fatal error|error|warning):\s+(?<message>.*)$/u;
3667
+ const MSVC_DIAGNOSTIC_PATTERN = /^(?<file>.+?)\((?<line>\d+)(?:,\d+)?\):\s+(?<severity>fatal error|error|warning)\s+(?<code>[A-Z]+\d+):\s+(?<message>.*)$/u;
3668
+ const CLANG_TIDY_PATTERN = /^(?<file>.+?):(?<line>\d+):(?<column>\d+):\s+(?<severity>error|warning):\s+(?<message>.*?)(?:\s+\[(?<check>[^\]]+)\])?$/u;
3669
+ const COOK_LINE_PREFIX_PATTERN = /^(?:\[[^\]]*\]\s*)*(?:[A-Za-z]+Helper:\s*[^:]*:\s*)?/u;
3670
+ const COOK_DIAGNOSTIC_PATTERN = /^(?<category>[A-Za-z_][A-Za-z0-9_]*):\s+(?<severity>Error|Warning)(?::)?\s+(?<message>.+)$/u;
3671
+ const UAT_DIAGNOSTIC_PATTERN = /^(?<severity>ERROR|WARNING):\s+(?<message>.+)$/u;
3672
+ function diagnosticKey(diagnostic) {
3673
+ return `${diagnostic.file ?? ""}|${diagnostic.line ?? ""}|${diagnostic.message}`;
3674
+ }
3675
+ function matchCookDiagnostic(rawLine) {
3676
+ const line = rawLine.trim().replace(COOK_LINE_PREFIX_PATTERN, "").trim();
3677
+ if (line.length === 0) return null;
3678
+ const categoryMatch = COOK_DIAGNOSTIC_PATTERN.exec(line);
3679
+ if (categoryMatch?.groups) {
3680
+ const { category, message, severity } = categoryMatch.groups;
3681
+ if (category === void 0 || message === void 0 || severity === void 0) return null;
3682
+ return {
3683
+ isError: severity === "Error",
3684
+ message: `${category}: ${message.trim()}`
3685
+ };
3686
+ }
3687
+ const uatMatch = UAT_DIAGNOSTIC_PATTERN.exec(line);
3688
+ if (!uatMatch?.groups) return null;
3689
+ const { message, severity } = uatMatch.groups;
3690
+ if (message === void 0 || severity === void 0) return null;
3691
+ return {
3692
+ isError: severity === "ERROR",
3693
+ message: message.trim()
3694
+ };
3695
+ }
3696
+ var CookDiagnosticCollector = class {
3697
+ maxEntriesPerSeverity;
3698
+ errors = [];
3699
+ warnings = [];
3700
+ seenErrors = /* @__PURE__ */ new Set();
3701
+ seenWarnings = /* @__PURE__ */ new Set();
3702
+ pending = "";
3703
+ truncatedFlag = false;
3704
+ constructor(maxEntriesPerSeverity) {
3705
+ this.maxEntriesPerSeverity = maxEntriesPerSeverity;
3706
+ }
3707
+ push(chunk) {
3708
+ const lines = (this.pending + chunk).split(/\r?\n/u);
3709
+ this.pending = lines.pop() ?? "";
3710
+ for (const line of lines) this.pushLine(line);
3711
+ }
3712
+ flush() {
3713
+ if (this.pending.length === 0) return;
3714
+ const remaining = this.pending;
3715
+ this.pending = "";
3716
+ this.pushLine(remaining);
3717
+ }
3718
+ snapshot() {
3719
+ return {
3720
+ errorCount: this.errors.length,
3721
+ errors: [...this.errors],
3722
+ truncated: this.truncatedFlag,
3723
+ warningCount: this.warnings.length,
3724
+ warnings: [...this.warnings]
3725
+ };
3726
+ }
3727
+ pushLine(rawLine) {
3728
+ const matched = matchCookDiagnostic(rawLine);
3729
+ if (!matched) return;
3730
+ const diagnostic = { message: matched.message };
3731
+ const target = matched.isError ? this.errors : this.warnings;
3732
+ const seen = matched.isError ? this.seenErrors : this.seenWarnings;
3733
+ const key = diagnosticKey(diagnostic);
3734
+ if (seen.has(key)) return;
3735
+ seen.add(key);
3736
+ if (target.length >= this.maxEntriesPerSeverity) {
3737
+ this.truncatedFlag = true;
3738
+ return;
3739
+ }
3740
+ target.push(diagnostic);
3741
+ }
3742
+ };
3743
+ function parseBuildDiagnostics(output) {
3744
+ const errors = [];
3745
+ const warnings = [];
3746
+ const seenErrors = /* @__PURE__ */ new Set();
3747
+ const seenWarnings = /* @__PURE__ */ new Set();
3748
+ for (const rawLine of output.split(/\r?\n/u)) {
3749
+ const line = rawLine.trim();
3750
+ if (line.length === 0) continue;
3751
+ const match = CLANG_DIAGNOSTIC_PATTERN.exec(line) ?? MSVC_DIAGNOSTIC_PATTERN.exec(line);
3752
+ if (!match?.groups) continue;
3753
+ const groups = match.groups;
3754
+ const { file, line: lineText, message, severity } = groups;
3755
+ if (file === void 0 || lineText === void 0 || message === void 0 || severity === void 0) continue;
3756
+ const code = typeof groups.code === "string" ? `${groups.code}: ` : "";
3757
+ const diagnostic = {
3758
+ file,
3759
+ line: Number.parseInt(lineText, 10),
3760
+ message: `${code}${message}`.trim()
3761
+ };
3762
+ const isError = severity === "error" || severity === "fatal error";
3763
+ const target = isError ? errors : warnings;
3764
+ const seen = isError ? seenErrors : seenWarnings;
3765
+ const key = diagnosticKey(diagnostic);
3766
+ if (seen.has(key)) continue;
3767
+ seen.add(key);
3768
+ target.push(diagnostic);
3769
+ }
3770
+ return {
3771
+ errorCount: errors.length,
3772
+ errors,
3773
+ warningCount: warnings.length,
3774
+ warnings
3775
+ };
3776
+ }
3777
+ function parseClangTidyFindings(output) {
3778
+ const findings = [];
3779
+ const seen = /* @__PURE__ */ new Set();
3780
+ for (const rawLine of output.split(/\r?\n/u)) {
3781
+ const match = CLANG_TIDY_PATTERN.exec(rawLine.trim());
3782
+ if (!match?.groups) continue;
3783
+ const groups = match.groups;
3784
+ const { file, line, message } = groups;
3785
+ if (file === void 0 || line === void 0 || message === void 0) continue;
3786
+ const severity = groups.severity === "error" ? "error" : "warning";
3787
+ const finding = {
3788
+ check: groups.check ?? "unknown",
3789
+ file,
3790
+ line: Number.parseInt(line, 10),
3791
+ message: message.trim(),
3792
+ severity
3793
+ };
3794
+ if (typeof groups.column === "string") finding.column = Number.parseInt(groups.column, 10);
3795
+ const key = `${finding.file}|${finding.line}|${finding.column ?? ""}|${finding.check}|${finding.message}`;
3796
+ if (seen.has(key)) continue;
3797
+ seen.add(key);
3798
+ findings.push(finding);
3799
+ }
3800
+ return findings;
3801
+ }
3470
3802
  //#endregion
3471
3803
  //#region src/moduleScaffold.ts
3472
3804
  const MODULE_DESCRIPTOR_TYPES = [
@@ -6882,7 +7214,7 @@ function isMissingFileError(error) {
6882
7214
  //#endregion
6883
7215
  //#region src/server.ts
6884
7216
  const SERVER_NAME = "ue-mcp-server";
6885
- const SERVER_VERSION = "0.3.0";
7217
+ const SERVER_VERSION = "0.4.0";
6886
7218
  const logger = createModuleLogger("bootstrap");
6887
7219
  const enginePingLogger = createModuleLogger("engine-ping");
6888
7220
  const httpApiLogger = createModuleLogger("http-api");
@@ -6901,8 +7233,12 @@ async function startServer(options) {
6901
7233
  readTimeoutMs: options.config.startupPingTimeoutMs
6902
7234
  });
6903
7235
  await pruneRegistryEntries(registry, store, options.config);
7236
+ let notifyRegistryChanged = () => {};
6904
7237
  const httpApi = createHttpApi({
6905
7238
  logger: httpApiLogger,
7239
+ onRegistryChanged: () => {
7240
+ notifyRegistryChanged();
7241
+ },
6906
7242
  projectIdResolver,
6907
7243
  registry,
6908
7244
  registryWriteQueue,
@@ -6915,7 +7251,7 @@ async function startServer(options) {
6915
7251
  const listeningHttpPort = getListeningHttpPort(httpApi, options.config.httpPort);
6916
7252
  let mcpServer;
6917
7253
  try {
6918
- mcpServer = createMcpServer({
7254
+ const createdMcpServer = createMcpServer({
6919
7255
  logger: mcpToolLogger,
6920
7256
  logSessionManager,
6921
7257
  pingTimeoutMs: options.config.startupPingTimeoutMs,
@@ -6924,6 +7260,10 @@ async function startServer(options) {
6924
7260
  registryWriteQueue,
6925
7261
  store
6926
7262
  });
7263
+ mcpServer = createdMcpServer;
7264
+ notifyRegistryChanged = () => {
7265
+ createdMcpServer.sendResourceListChanged();
7266
+ };
6927
7267
  if (options.connectMcpTransport !== false) await mcpServer.connect(new StdioServerTransport());
6928
7268
  } catch (error) {
6929
7269
  try {
@@ -6977,8 +7317,14 @@ function createMcpServer(dependencies) {
6977
7317
  const server = new McpServer({
6978
7318
  name: SERVER_NAME,
6979
7319
  version: SERVER_VERSION
6980
- }, { capabilities: { tools: {} } });
7320
+ }, { capabilities: {
7321
+ prompts: {},
7322
+ resources: {},
7323
+ tools: {}
7324
+ } });
6981
7325
  registerMcpTools(server, dependencies);
7326
+ registerMcpResources(server, dependencies);
7327
+ registerMcpPrompts(server);
6982
7328
  return server;
6983
7329
  }
6984
7330
  async function pruneRegistryEntries(registry, store, config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uemcp/server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for making Unreal Engine and C++ development easier",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",