diff --git a/README.md b/README.md index 4ba5797..c49d466 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ the build you pass as `?build=2.31.13+op77.54`. | `open77_fivem_equivalent` | what to use on Open77, and what is deliberately absent | | `open77_manifest_schema`, `open77_server_config_schema` | `open77.lua` and `server.jsonc` | | `open77_changes`, `open77_build` | what a build adds; which build this session answers for | -| `open77_workspace`, `open77_validate`, `open77_new_resource` | local only: detect the server, validate a resource (the server's own `--lint` verdict when the binary is next to you), scaffold one | +| `open77_workspace`, `open77_validate`, `open77_new_resource` | local only: detect the server, validate a resource (the server's own `--lint` verdict when the binary is next to you; command names another loaded resource already registers), scaffold one | | `open77_server_status`, `open77_resources`, `open77_resource`, `open77_console_tail`, `open77_console_command`, `open77_tunables` | local only, through Warden: status, start/stop/restart/reload/validate, the log, the console, tunables | | `open77_workshop_search`, `open77_workshop_release`, `open77_workshop_plan`, `open77_workshop_install`, `open77_workshop_job` | local only, through Warden: browse the Workshop, plan, install with the human's consent, follow the job | @@ -52,6 +52,26 @@ Resources: `open77://skill` (the method), `open77://guide/{slug}`, `open77://api Every answer states the build it answers for. A native newer than that build, or in no published build, is reported as **NOT AVAILABLE**, never silently served. +## What's new + +**0.1.3** -- fixes measured by four MCP-only agents on 2026-09-18 against the index for +2.31.13+op77.76: + +- `open77_validate` accepts the documented `.await` forms (`Open77.database.query.await(sql, params)` + and the other database methods) and the global `MySQL`, which is `Open77.database` on every build + that has the table; both still go through the permission check, so `database.access` is required + as before. A name whose card documents no `.await` form gets a warning instead of a false + "not in the catalogue". The alias table lives in `src/index/conventions.ts`, each entry pointing at + the guide section that documents it, and the test suite checks those sections still say so. +- `open77_api client:` works as the mirror of `server:`; a prefix contradicting + `runtime` says so instead of "does not exist". +- `open77_api server:exports` and `server:print` answer from the guides (`server-exports#publish-a-service`, + `server-api#logging`) until the index carries their cards; `open77_namespace _G` lists them as a footnote. +- `open77_validate` warns when a literal `RegisterCommand("")` reuses a name another resource + under the detected server already registers on the same side; the runtime keeps one handler + silently. Only when a server is detected (`open77_workspace`): a session with no server next to it + has nothing to compare against. + ## Editor completion ```bash diff --git a/package-lock.json b/package-lock.json index 104438d..44b05d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@open2077/mcp", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@open2077/mcp", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index b852e70..ba172fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open2077/mcp", - "version": "0.1.2", + "version": "0.1.3", "description": "Open77 Devkit MCP: the Open77 Lua API, permissions, events and game data for coding agents, pinned to the server build you run.", "license": "MIT", "type": "module", diff --git a/skill/SKILL.md b/skill/SKILL.md index 75c3b7a..deab29f 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -18,11 +18,11 @@ owner's build, however plausible it looks. ``` open77_build which build you are answering for; say it in your first message open77_search "" cards, guides, events, permissions, FiveM aliases -open77_api the card: runtime, permissions, since, reasons, example +open77_api the card: runtime, permissions, since, reasons, example (server: / client: pins a side) open77_guide the how-to behind the card (a search ref pastes as is) open77_manifest_schema before writing open77.lua write -open77_validate (local MCP) syntax, manifest, unknown natives, wrong side, permissions, build +open77_validate (local MCP) syntax, manifest, unknown natives, wrong side, permissions, build, command names taken by another resource open77_resource reload (local MCP, through Warden) then open77_console_tail for the result ``` diff --git a/src/index/conventions.ts b/src/index/conventions.ts new file mode 100644 index 0000000..cf829b0 --- /dev/null +++ b/src/index/conventions.ts @@ -0,0 +1,117 @@ +/** + * What the runtime installs that the cards do not describe as cards. + * + * The index is generated from the API cards and the guides; two shapes only + * exist in the guides' prose and would otherwise read as "not in the + * catalogue": + * + * - a global that aliases a whole namespace: `MySQL` is `Open77.database` + * ("`Open77.database` and the global `MySQL` are the same + * oxmysql-compatible table" -- the `Open77.database.query` card and the + * `server-api#database` guide); + * - the `.await` sub-form of a native: `Open77.database.query.await(sql, + * params)` is the same native as `Open77.database.query`, called without a + * callback from a managed coroutine (the guide's "Await form" column); + * - a global documented by a guide table row that the generator did not + * turn into a card: the server `exports(name, fn)` and `print(...)`. + * + * Each table entry names the guide section that documents it, and every + * entry is applied only when the index it is used with still carries the + * thing it points at: an alias needs its target namespace on the build, a + * guide-only global is dormant as soon as a real card exists for it. The + * embedded-index test checks that the guide sections named here exist and + * mention the name, so a table entry cannot outlive its documentation. + */ + +import type { ApiCard, DevIndex } from "./types.js"; + +export interface NamespaceAlias { + /** The global as written in a resource (`MySQL`). */ + alias: string; + /** The catalogued namespace it stands for (`Open77.database`). */ + target: string; + runtime: "client" | "server"; + /** `slug#anchor` of the guide section that documents the alias. */ + guide: string; +} + +export const NAMESPACE_ALIASES: readonly NamespaceAlias[] = [ + { alias: "MySQL", target: "Open77.database", runtime: "server", guide: "server-api#database" }, +]; + +/** The trailing member that turns a callback-form native into its await form. */ +export const AWAIT_SUFFIX = ".await"; + +export interface GuideOnlyGlobal { + name: string; + runtime: "client" | "server"; + signature: string; + summary: string; + /** `slug#anchor` sections, the most useful first. */ + guides: string[]; +} + +export const GUIDE_ONLY_GLOBALS: readonly GuideOnlyGlobal[] = [ + { + name: "exports", + runtime: "server", + signature: "exports(name: string, fn: function)", + summary: "Publishes or replaces a server export of this resource; true, or nil and a reason. Indexed instead of called it is the synchronous proxy: exports.other:name(...) / exports['other-resource']:name(...) runs that export inline.", + guides: ["server-exports#publish-a-service", "server-api#cross-resource-exports", "server-exports#execution-identity-and-permissions"], + }, + { + name: "print", + runtime: "server", + signature: "print(...: any)", + summary: "Writes a resource-prefixed INF entry to the server log; the values are joined with a tab. Citizen.Trace is the debug-level sibling.", + guides: ["server-api#logging", "server-api#runtime-scheduler-events-commands-and-json"], + }, +]; + +/** + * The namespaces the index has cards for. An alias applies only when its + * target is among them; on an index without `Open77.database` the global + * `MySQL` is as unknown as any other undeclared name. + */ +export function namespacesOf(cards: readonly ApiCard[]): Set { + return new Set(cards.map((c) => c.namespace)); +} + +/** + * `MySQL.query.await` -> `Open77.database.query.await`; anything else comes + * back unchanged. The runtime is not checked here: the caller resolves the + * card and reports a server native in a client script as it does for any + * other name. + */ +export function resolveNamespaceAlias(qualified: string, namespaces: ReadonlySet): string { + const dot = qualified.indexOf("."); + const head = dot < 0 ? qualified : qualified.slice(0, dot); + for (const alias of NAMESPACE_ALIASES) { + if (alias.alias === head && namespaces.has(alias.target)) return alias.target + qualified.slice(head.length); + } + return qualified; +} + +/** + * `Open77.database.query.await` -> `Open77.database.query` when the shorter + * name is catalogued; otherwise the name as given. `has` answers for the + * catalogue the caller resolves against. + */ +export function stripAwaitForm(qualified: string, has: (name: string) => boolean): { qualified: string; awaited: boolean } { + if (qualified.endsWith(AWAIT_SUFFIX)) { + const base = qualified.slice(0, -AWAIT_SUFFIX.length); + if (base.includes(".") && has(base)) return { qualified: base, awaited: true }; + } + return { qualified, awaited: false }; +} + +/** Whether a card documents an `.await` form (description, returns or example). */ +export function documentsAwaitForm(card: ApiCard): boolean { + return /\bawait\b/i.test(card.description) || card.returns.some((r) => /\bawait\b/i.test(r)) || /\.await\s*\(/.test(card.example ?? ""); +} + +/** The guide-only globals that still have no card on this index, for one runtime. */ +export function guideOnlyGlobals(index: DevIndex, runtime?: "client" | "server"): GuideOnlyGlobal[] { + const carded = new Set(index.cards.filter((c) => c.namespace === "_G").map((c) => `${c.runtime}:${c.name}`)); + return GUIDE_ONLY_GLOBALS.filter((g) => (!runtime || g.runtime === runtime) && !carded.has(`${g.runtime}:${g.name}`)); +} diff --git a/src/server.ts b/src/server.ts index 94feb48..4f1c09e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { opNumber } from "./index/builder.js"; +import { guideOnlyGlobals, type GuideOnlyGlobal } from "./index/conventions.js"; import { loadCatalogue, readStub } from "./index/loader.js"; import { IndexSearch, type SearchKind } from "./index/search.js"; import type { ApiCard, DevIndex, Guide, GuideSection } from "./index/types.js"; @@ -154,7 +155,7 @@ export function createMcpServer(context: ServerContext): McpServer { title: "Read one native's card", description: "The full card of one Lua native: signature, description, permissions the manifest must declare, reasons it can return, " + - "the first build that has it, example, related guides. Accepts a qualified name (Open77.map.getWaypoint, TriggerClientEvent) or a route id (server:Open77.vehicles.spawn).", + "the first build that has it, example, related guides. Accepts a qualified name (Open77.map.getWaypoint, TriggerClientEvent) or a side-pinned name (server:Open77.vehicles.spawn, client:Open77.camera.attach).", inputSchema: { name: z.string().min(1), runtime: RUNTIME.optional().describe("Disambiguates a name that exists on both runtimes"), @@ -162,12 +163,17 @@ export function createMcpServer(context: ServerContext): McpServer { annotations: { readOnlyHint: true, openWorldHint: false }, }, async ({ name, runtime }) => { + const { bare, side } = splitRoute(name); + if (side && runtime && side !== runtime) return text(`${name} names the ${side} side but runtime=${runtime} was passed; drop one of them.`); const cards = findCards(name, runtime); - if (!cards.length) { + // `exports` and `print` on the server are documented by guide table + // rows and have no card yet: a real answer, not "missing". + const fromGuides = guideOnlyGlobals(index, runtime ?? side).filter((g) => g.name === bare && !cards.some((c) => c.runtime === g.runtime)); + if (!cards.length && !fromGuides.length) { const near = search.search(name, { kinds: ["card"], limit: 5 }).map((h) => h.title); return text(`No native named ${name} in the catalogue for ${context.resolved.build}.${near.length ? ` Closest: ${near.join(", ")}.` : ""} Do not call it: a name absent from the catalogue does not exist on this build.`); } - return text(cards.map((c) => renderCard(c, context, index.guides)).join("\n\n---\n\n")); + return text([...cards.map((c) => renderCard(c, context, index.guides)), ...fromGuides.map((g) => renderGuideOnlyGlobal(g, context))].join("\n\n---\n\n")); }, ); @@ -202,7 +208,9 @@ export function createMcpServer(context: ServerContext): McpServer { const avail = availabilityNote(c, context); return `- ${cardHeader(c)} — ${c.summary}${c.permissions.length ? ` [${c.permissions.join(", ")}]` : ""}${c.since ? ` (since ${c.since})` : " (unreleased)"}${avail ? ` — ${avail}` : ""}`; }); - return text([`${cards.length} natives in ${namespace} — ${buildLine(context)}`, ...lines].join("\n")); + const extra = wanted === "_g" ? guideOnlyGlobals(index, runtime) : []; + const footnote = extra.length ? ["", `Also present, documented in the guides only (no card yet): ${extra.map((g) => `${g.runtime} ${g.signature} — ${g.guides[0]}`).join("; ")}. open77_api ${extra[0]!.runtime}:${extra[0]!.name} renders it.`] : []; + return text([`${cards.length} natives in ${namespace} — ${buildLine(context)}`, ...lines, ...footnote].join("\n")); }, ); @@ -619,23 +627,57 @@ export function createMcpServer(context: ServerContext): McpServer { ); } - function findCards(name: string, runtime?: "client" | "server"): ApiCard[] { + /** + * `server:Open77.x` / `client:Open77.x` -> the bare name and the side. A + * server route id really is spelled `server:`; a client route id is the + * bare name, so `client:` is accepted as its mirror rather than looked up. + */ + function splitRoute(name: string): { bare: string; side?: "client" | "server" } { const trimmed = name.trim().replace(/\(.*$/, ""); + const routed = /^(client|server):(.+)$/i.exec(trimmed); + if (!routed) return { bare: trimmed }; + return { bare: routed[2]!.trim(), side: routed[1]!.toLowerCase() as "client" | "server" }; + } + + function findCards(name: string, runtime?: "client" | "server"): ApiCard[] { // A client route id is the bare qualified name, so a bare name hits the // route map first and used to win outright -- `Open77.vehicles.setFrozen` // with runtime "server" answered the CLIENT card (measured 2026-09-16 on - // 0.1.0). Only an explicit route id (`server:` / `client:`) is a direct - // hit; a bare name goes through the qualified map, where the runtime - // filter applies and a name both runtimes carry returns both cards. - if (trimmed.includes(":")) { - const direct = byRoute.get(trimmed); - if (direct) return runtime && direct.runtime !== runtime ? [] : [direct]; - } - const cards = byQualified.get(trimmed.toLowerCase()) ?? []; - return runtime ? cards.filter((c) => c.runtime === runtime) : cards; + // 0.1.0). A bare name goes through the qualified map, where the runtime + // filter applies and a name both runtimes carry returns both cards; an + // explicit `server:` / `client:` prefix pins the side the same way + // (`client:` was refused until 0.1.3, measured 2026-09-18). + const { bare, side } = splitRoute(name); + if (side && runtime && side !== runtime) return []; + const wanted = side ?? runtime; + const cards = byQualified.get(bare.toLowerCase()) ?? []; + return wanted ? cards.filter((c) => c.runtime === wanted) : cards; } } +/** The card-shaped answer for a global that only the guides document. */ +export function renderGuideOnlyGlobal(global: GuideOnlyGlobal, context: ServerContext): string { + const sections = global.guides + .map((ref) => { + const [slug, anchor] = ref.split("#"); + const section = context.index.guides.find((g) => g.slug === slug)?.sections.find((s) => s.anchor === anchor); + return section ? { ref, text: section.text } : null; + }) + .filter((s): s is { ref: string; text: string } => s !== null); + return [ + `# ${global.runtime} ${global.signature}`, + "", + global.summary, + "", + `- runtime: ${global.runtime}`, + "- catalogue: no card on this index; the global is documented by the guides below (the generator has not turned that table row into a card yet)", + `- guides: ${global.guides.join(", ")}`, + ...(sections.length ? ["", `## ${sections[0]!.ref}`, "", sections[0]!.text] : ["", `Read open77_guide ${global.guides[0]} for the worked example.`]), + "", + `_${buildLine(context)}_`, + ].join("\n"); +} + export function instructions(context: ServerContext): string { return [ `Open77 Devkit MCP (build ${context.resolved.build}). Open77 turns Cyberpunk 2077 into a server-driven multiplayer platform; gameplay is written as Lua resources with a client and a server side.`, diff --git a/src/workspace/tools.ts b/src/workspace/tools.ts index 2f797d2..6ebe3c0 100644 --- a/src/workspace/tools.ts +++ b/src/workspace/tools.ts @@ -60,7 +60,8 @@ export function registerLocalTools(server: McpServer, context: ServerContext, in title: "Validate a resource", description: "Static checks of one resource directory against the served build: manifest grammar, script files, Lua syntax (5.3-compatible parser; exact 5.4 via the server's --lint when present), " + - "unknown Open77.* natives, client natives in server scripts and the reverse, permissions used but not declared, natives newer than the build or unreleased. " + + "unknown Open77.* natives, client natives in server scripts and the reverse, permissions used but not declared, natives newer than the build or unreleased, " + + "and command names another resource of this server already registers (a warning; literal RegisterCommand names only). " + "Argument `resource`: a resource name from open77_workspace or an absolute path.", inputSchema: { resource: z.string().describe("Resource name under the resources root, or a directory path") }, annotations: { readOnlyHint: true, openWorldHint: false }, @@ -68,7 +69,10 @@ export function registerLocalTools(server: McpServer, context: ServerContext, in async ({ resource }) => { const dir = await resolveResourceDir(resource, workspace); if (!dir) return text(`No resource ${resource}: not a directory and not under ${workspace.resourcesRoot ?? "an unknown resources root"}.`); - const findings = await validateResource(dir, context); + // The other resources the server can load, for the command-name + // collision check; a session with no server next to it has none. + const loadedResources = (await Promise.all(workspace.resources.map((name) => findResourceDir(workspace, name)))).filter((d): d is string => d !== null); + const findings = await validateResource(dir, context, { loadedResources }); const runtime = await runtimeLint(workspace.serverBinary, dir); if (runtime) { // The runtime's syntax verdict is exact; drop the approximate parser's diff --git a/src/workspace/validate.ts b/src/workspace/validate.ts index 2e9b616..1595fcf 100644 --- a/src/workspace/validate.ts +++ b/src/workspace/validate.ts @@ -8,11 +8,17 @@ * failure is reported as a warning and the exact verdict is the server's * own `--lint`, when that build has it); * - an `Open77.*` call the catalogue does not know -- on this build it does - * not exist, whatever it looks like; + * not exist, whatever it looks like. `X.await(...)` is the native `X` + * called in its await form, and the global `MySQL` is `Open77.database` + * (`conventions.ts`), so neither reads as unknown; * - a client native in a server script, or the reverse; a shared script * using a one-sided native; * - a native whose permission the manifest does not declare; - * - a native newer than the served build, or in no published build. + * - a native newer than the served build, or in no published build; + * - when the resources the server loads are known (open77_workspace), a + * command name another loaded resource already registers on the same + * side -- the second registration is silent at runtime, so it is a + * warning here. * * Findings are facts about the files and the catalogue; the tool never * rewrites anything. @@ -24,6 +30,7 @@ import path from "node:path"; import { promisify } from "node:util"; import luaparse from "luaparse"; import { opNumber } from "../index/builder.js"; +import { NAMESPACE_ALIASES, documentsAwaitForm, namespacesOf, resolveNamespaceAlias, stripAwaitForm } from "../index/conventions.js"; import type { ApiCard, ManifestDirective } from "../index/types.js"; import type { ServerContext } from "../server.js"; @@ -120,12 +127,18 @@ async function expandGlob(root: string, pattern: string): Promise { return found.sort(); } -const RE_NATIVE = /\b((?:Open77|WebUI|Citizen|MySQL)(?:\.[A-Za-z_]\w*)+)\s*\(/g; +// The heads a dotted native can start with: the catalogued namespaces' roots +// and the globals that alias one of them (`MySQL` for `Open77.database`). +const ALIAS_HEADS = NAMESPACE_ALIASES.map((a) => a.alias); +const RE_NATIVE = new RegExp(`\\b((?:${["Open77", "WebUI", "Citizen", ...ALIAS_HEADS].join("|")})(?:\\.[A-Za-z_]\\w*)+)\\s*\\(`, "g"); const RE_GLOBAL = /(? { +export interface ValidateOptions { + /** + * Absolute directories of the other resources the server loads (what + * open77_workspace lists), for the command-name collision check. The + * validated directory itself is skipped. Without it the check is off: the + * validator has no other way to know what else the server runs. + */ + loadedResources?: string[]; +} + +export async function validateResource(dir: string, context: ServerContext, options: ValidateOptions = {}): Promise { const findings: Finding[] = []; const { index, resolved } = context; const served = opNumber(resolved.build); @@ -201,7 +224,7 @@ export async function validateResource(dir: string, context: ServerContext): Pro } const globalsBySide = { client: new Set(), server: new Set() }; for (const card of index.cards) if (card.namespace === "_G") globalsBySide[card.runtime].add(card.name); - const namespaces = new Set(index.cards.map((c) => c.namespace)); + const namespaces = namespacesOf(index.cards); const usedPermissions = new Map>(); for (const script of scripts) { @@ -225,26 +248,34 @@ export async function validateResource(dir: string, context: ServerContext): Pro } for (let i = 0; i < lines.length; i += 1) { const line = lines[i]!; - const check = (qualified: string, isGlobal: boolean) => { + // `written` is the name as the script spells it; `qualified` is the card + // it resolves to once the namespace alias (`MySQL.` -> `Open77.database.`) + // and the `.await` sub-form are folded away. Messages show both when they + // differ, so the line is findable and the card is nameable. + const check = (written: string, isGlobal: boolean) => { + const aliased = resolveNamespaceAlias(written, namespaces); + const { qualified, awaited } = stripAwaitForm(aliased, (n) => byQualified.has(n)); + const shown = qualified === written ? written : `${written} (${qualified})`; const cards = byQualified.get(qualified) ?? []; if (!cards.length) { - if (!isGlobal) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${qualified} is not in the catalogue for ${resolved.build}: it does not exist on this build`, fix: `open77_search "${qualified.split(".").slice(-1)[0]}" for the real name` }); + if (!isGlobal) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${shown} is not in the catalogue for ${resolved.build}: it does not exist on this build`, fix: `open77_search "${qualified.split(".").slice(-1)[0]}" for the real name` }); return; } const sides = new Set(cards.map((c) => c.runtime)); const wanted: ("client" | "server")[] = script.side === "shared" ? ["client", "server"] : [script.side]; const missing = wanted.filter((s) => !sides.has(s)); if (missing.length && script.side !== "shared") { - findings.push({ severity: "error", file: script.file, line: i + 1, message: `${qualified} is a ${[...sides].join("/")} native used in a ${script.side} script`, fix: `move the call to a ${[...sides][0]}_script, or use the ${script.side} equivalent (open77_api ${qualified})` }); + findings.push({ severity: "error", file: script.file, line: i + 1, message: `${shown} is a ${[...sides].join("/")} native used in a ${script.side} script`, fix: `move the call to a ${[...sides][0]}_script, or use the ${script.side} equivalent (open77_api ${qualified})` }); return; } - if (missing.length) findings.push({ severity: "warning", file: script.file, line: i + 1, message: `${qualified} exists only on ${[...sides].join("/")}; a shared script runs on both`, fix: "guard with IsDuplicityVersion() or move to a one-sided script" }); + if (missing.length) findings.push({ severity: "warning", file: script.file, line: i + 1, message: `${shown} exists only on ${[...sides].join("/")}; a shared script runs on both`, fix: "guard with IsDuplicityVersion() or move to a one-sided script" }); for (const card of cards.filter((c) => wanted.includes(c.runtime))) { - if (!card.since) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${qualified} (${card.runtime}) is in no published server build`, fix: "wait for a release or use another native" }); - else if (opNumber(card.since) > served) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${qualified} (${card.runtime}) needs ${card.since}; this build is ${resolved.build}`, fix: `open77_changes ${resolved.build} ${card.since} lists what the update brings` }); + if (!card.since) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${shown} (${card.runtime}) is in no published server build`, fix: "wait for a release or use another native" }); + else if (opNumber(card.since) > served) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${shown} (${card.runtime}) needs ${card.since}; this build is ${resolved.build}`, fix: `open77_changes ${resolved.build} ${card.since} lists what the update brings` }); + if (awaited && !documentsAwaitForm(card)) findings.push({ severity: "warning", file: script.file, line: i + 1, message: `${written}: the ${card.runtime} card of ${qualified} documents no .await form on ${resolved.build}`, fix: `open77_api ${card.route_id} shows the forms it has; a native returning an Open77.Promise is awaited with :await() on the promise` }); for (const permission of card.permissions) { if (!usedPermissions.has(permission)) usedPermissions.set(permission, new Set()); - usedPermissions.get(permission)!.add(qualified); + usedPermissions.get(permission)!.add(written); } } }; @@ -254,17 +285,23 @@ export async function validateResource(dir: string, context: ServerContext): Pro if (globalsBySide.client.has(global) || globalsBySide.server.has(global)) check(global, true); } for (const match of line.matchAll(RE_NATIVE_REF)) { - const segments = match[1]!.split("."); + const written = match[1]!; + const aliased = resolveNamespaceAlias(written, namespaces); + const segments = aliased.split("."); // Longest catalogued prefix with at least a namespace and a member. let hit: string | null = null; for (let n = segments.length; n >= 3; n -= 1) { const candidate = segments.slice(0, n).join("."); if (byQualified.has(candidate)) { hit = candidate; break; } } - if (hit) check(hit, false); + // The alias only rewrites the head, so the written prefix is the written + // name minus the same tail the hit dropped. + if (hit) check(written.slice(0, written.length - (aliased.length - hit.length)), false); else if (segments.length >= 3 && namespaces.has(segments.slice(0, 2).join("."))) { // A member of a real namespace that no card describes: as unknown as a call would be. - findings.push({ severity: "error", file: script.file, line: i + 1, message: `${segments.slice(0, 3).join(".")} is not in the catalogue for ${resolved.build}: it does not exist on this build`, fix: `open77_namespace ${segments.slice(0, 2).join(".")} lists the members` }); + const member = segments.slice(0, 3).join("."); + const shown = written.startsWith(member) ? member : `${written.split(".").slice(0, 2).join(".")} (${member})`; + findings.push({ severity: "error", file: script.file, line: i + 1, message: `${shown} is not in the catalogue for ${resolved.build}: it does not exist on this build`, fix: `open77_namespace ${segments.slice(0, 2).join(".")} lists the members` }); } } for (const match of line.matchAll(RE_SANDBOX_LIBRARY)) { @@ -288,6 +325,78 @@ export async function validateResource(dir: string, context: ServerContext): Pro for (const permission of declared) { if (!usedPermissions.has(permission)) findings.push({ severity: "note", file: "open77.lua", message: `permission ${permission} is declared but no catalogued native in the scripts checks it (it may gate a service, an event or a WebUI feature)` }); } + findings.push(...(await commandCollisions(dir, options.loadedResources ?? [], index.manifestSchema.directives))); + return findings; +} + +export interface RegisteredCommand { + name: string; + side: "client" | "server" | "shared"; + file: string; + line: number; +} + +/** + * Every `RegisterCommand("", ...)` in the scripts a resource's + * manifest lists, with the side the script runs on. Names built at run time + * (`RegisterCommand(prefix .. "x", ...)`) are invisible to this scan, which + * is why its findings are warnings. + */ +export async function registeredCommands(dir: string, directives: ManifestDirective[]): Promise { + const source = await readFile(path.join(dir, "open77.lua"), "utf8").catch(() => null); + if (source === null) return []; + const manifest = parseManifest(source, directives); + const found: RegisteredCommand[] = []; + for (const [key, side] of [["client_scripts", "client"], ["server_scripts", "server"], ["shared_scripts", "shared"]] as const) { + for (const pattern of manifest.lists[key] ?? []) { + for (const file of await expandGlob(dir, pattern)) { + if (!file.endsWith(".lua")) continue; + const code = await readFile(path.join(dir, file), "utf8").catch(() => null); + if (code === null) continue; + const stripped = code.replace(RE_COMMENT_BLOCK, (m) => m.replace(/[^\n]/g, " ")).replace(RE_COMMENT_LINE, ""); + const lines = stripped.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + for (const match of lines[i]!.matchAll(RE_REGISTER_COMMAND)) found.push({ name: match[2]!.trim(), side, file, line: i + 1 }); + } + } + } + } + return found; +} + +const sameSide = (a: RegisteredCommand["side"], b: RegisteredCommand["side"]) => a === b || a === "shared" || b === "shared"; + +/** + * A command the validated resource registers under a name another loaded + * resource already registers on the same side. The client and the server + * keep separate command registries, so a client `/heal` next to a server + * `/heal` is not a collision; two server `/heal` are, and the runtime does + * not say so -- the second handler simply never runs. + */ +export async function commandCollisions(dir: string, loadedResources: string[], directives: ManifestDirective[]): Promise { + if (!loadedResources.length) return []; + const mine = await registeredCommands(dir, directives); + if (!mine.length) return []; + const self = path.resolve(dir).toLowerCase(); + const findings: Finding[] = []; + for (const other of loadedResources) { + if (path.resolve(other).toLowerCase() === self) continue; + const theirs = await registeredCommands(other, directives); + if (!theirs.length) continue; + const label = path.basename(other); + for (const command of mine) { + for (const taken of theirs) { + if (taken.name !== command.name || !sameSide(taken.side, command.side)) continue; + findings.push({ + severity: "warning", + file: command.file, + line: command.line, + message: `command /${command.name} is already registered by ${label} (${taken.file}:${taken.line}, ${taken.side} side); the runtime keeps one handler and never says which`, + fix: `rename the command, or drop the duplicate if ${label} is the owner`, + }); + } + } + } return findings; } diff --git a/tests/validator-013.test.ts b/tests/validator-013.test.ts new file mode 100644 index 0000000..60c8b50 --- /dev/null +++ b/tests/validator-013.test.ts @@ -0,0 +1,239 @@ +/** + * The 0.1.3 fixes, each measured by an MCP-only agent on 2026-09-18 against + * the index for 2.31.13+op77.76: + * - `Open77.database.*.await` and the `MySQL` alias read as unknown natives; + * - `open77_api client:` was refused while `server:` worked; + * - `server:exports` / `server:print` answered "missing" although the + * server-exports guide uses both; + * - a command name another loaded resource already registers went unnoticed. + */ + +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { GUIDE_ONLY_GLOBALS, NAMESPACE_ALIASES, guideOnlyGlobals, resolveNamespaceAlias, stripAwaitForm } from "../src/index/conventions.js"; +import { loadIndex } from "../src/index/loader.js"; +import type { ServerContext } from "../src/server.js"; +import { detectWorkspace, findResourceDir } from "../src/workspace/detect.js"; +import { commandCollisions, registeredCommands, validateResource } from "../src/workspace/validate.js"; + +const INDEX_DIR = path.resolve("index"); + +async function context(build?: string): Promise { + const index = await loadIndex(INDEX_DIR); + return { + index, + resolved: { directory: INDEX_DIR, build: build ?? index.manifest.build, origin: "embedded" }, + packageVersion: "test", + skillPath: path.resolve("skill/SKILL.md"), + }; +} + +async function mcpClient(ctx: ServerContext) { + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js"); + const { createMcpServer } = await import("../src/server.js"); + const server = createMcpServer(ctx); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientSide); + const ask = async (name: string, args: Record) => { + const result = await client.callTool({ name, arguments: args }); + return (result.content as { type: string; text: string }[]).map((c) => c.text).join("\n"); + }; + const close = async () => { + await client.close(); + await server.close(); + }; + return { ask, close }; +} + +const BANK_SERVER = [ + "MySQL.ready(function()", + " MySQL.query.await([[CREATE TABLE IF NOT EXISTS bank (id BIGINT PRIMARY KEY, money INT NOT NULL)]])", + "end)", + "CreateThread(function()", + ' local rows = Open77.database.query.await("SELECT id, money FROM bank WHERE id = ?", { 1 })', + ' Open77.database.update.await("UPDATE bank SET money = ? WHERE id = ?", { 10, 1 })', + " local ready = MySQL.isReady()", + ' MySQL.update("DELETE FROM bank WHERE id = ?", { 1 }, function(affected) print(affected) end)', + " local single = MySQL.single.await", + "end)", + "", +].join("\n"); + +test("the conventions table is backed by the index it is used with", async () => { + const ctx = await context(); + const bySlug = new Map(ctx.index.guides.map((g) => [g.slug, g])); + const sectionText = (ref: string) => { + const [slug, anchor] = ref.split("#"); + const section = bySlug.get(slug!)?.sections.find((s) => s.anchor === anchor); + assert.ok(section, `${ref} is a section of the embedded index`); + return section.text; + }; + for (const alias of NAMESPACE_ALIASES) { + const text = sectionText(alias.guide); + assert.ok(text.includes(`\`${alias.alias}\``), `${alias.guide} names ${alias.alias}`); + assert.ok(text.includes(`\`${alias.target}\``), `${alias.guide} names ${alias.target}`); + assert.ok(ctx.index.cards.some((c) => c.namespace === alias.target && c.runtime === alias.runtime), `${alias.target} has ${alias.runtime} cards`); + } + for (const global of GUIDE_ONLY_GLOBALS) { + assert.ok(global.guides.length, `${global.name} points at a guide`); + assert.ok(sectionText(global.guides[0]!).includes(global.name), `${global.guides[0]} mentions ${global.name}`); + } + // Dormant as soon as a card exists: the client `exports` has one. + assert.ok(!guideOnlyGlobals(ctx.index, "client").some((g) => g.name === "exports")); + assert.deepEqual(guideOnlyGlobals(ctx.index, "server").map((g) => g.name).sort(), ["exports", "print"]); + + const namespaces = new Set(ctx.index.cards.map((c) => c.namespace)); + assert.equal(resolveNamespaceAlias("MySQL.query.await", namespaces), "Open77.database.query.await"); + assert.equal(resolveNamespaceAlias("MySQL", namespaces), "Open77.database"); + assert.equal(resolveNamespaceAlias("Open77.chat.send", namespaces), "Open77.chat.send"); + assert.equal(resolveNamespaceAlias("MySQL.query", new Set(["Open77.chat"])), "MySQL.query", "no alias on an index without the target namespace"); + const has = (n: string) => n === "Open77.database.query"; + assert.deepEqual(stripAwaitForm("Open77.database.query.await", has), { qualified: "Open77.database.query", awaited: true }); + assert.deepEqual(stripAwaitForm("Open77.database.query", has), { qualified: "Open77.database.query", awaited: false }); + assert.deepEqual(stripAwaitForm("Open77.nope.await", has), { qualified: "Open77.nope.await", awaited: false }); +}); + +test("validate accepts the database .await forms and the MySQL alias, and still enforces database.access", async () => { + const ctx = await context("2.31.13+op77.76"); + const root = await mkdtemp(path.join(os.tmpdir(), "open77-db-")); + try { + const bank = path.join(root, "bank_res"); + await mkdir(path.join(bank, "server"), { recursive: true }); + await writeFile(path.join(bank, "open77.lua"), 'resource "bank_res"\npermissions { "database.access" }\nserver_script "server/main.lua"\n', "utf8"); + await writeFile(path.join(bank, "server/main.lua"), BANK_SERVER, "utf8"); + const clean = await validateResource(bank, ctx); + assert.deepEqual(clean.filter((f) => f.severity !== "note"), [], JSON.stringify(clean, null, 1)); + assert.ok(!clean.some((f) => /declared but no catalogued native/.test(f.message)), "database.access is seen as used"); + + // The same scripts without the permission: one error naming the calls as written. + await writeFile(path.join(bank, "open77.lua"), 'resource "bank_res"\nserver_script "server/main.lua"\n', "utf8"); + const denied = await validateResource(bank, ctx); + const errors = denied.filter((f) => f.severity === "error").map((f) => f.message); + assert.equal(errors.length, 1, errors.join("\n")); + assert.match(errors[0]!, /^permission database\.access is required by MySQL\.ready, MySQL\.query\.await, Open77\.database\.query\.await/); + + // Unknown members and undocumented .await forms are still caught, under the written name. + const odd = path.join(root, "odd_res"); + await mkdir(path.join(odd, "server"), { recursive: true }); + await mkdir(path.join(odd, "client"), { recursive: true }); + await writeFile(path.join(odd, "open77.lua"), 'resource "odd_res"\npermissions { "database.access" }\nserver_script "server/main.lua"\nclient_script "client/main.lua"\n', "utf8"); + await writeFile(path.join(odd, "server/main.lua"), 'MySQL.nonsense("x")\nlocal z = MySQL.zzz.yyy\nOpen77.chat.send.await(1, "hi")\n', "utf8"); + await writeFile(path.join(odd, "client/main.lua"), 'MySQL.query.await("SELECT 1")\n', "utf8"); + const findings = (await validateResource(odd, ctx)).map((f) => `${f.severity} ${f.file}:${f.line ?? 0} ${f.message}`); + assert.ok(findings.some((m) => /^error server\/main.lua:1 MySQL.nonsense \(Open77.database.nonsense\) is not in the catalogue/.test(m)), findings.join("\n")); + assert.ok(findings.some((m) => /^error server\/main.lua:2 MySQL.zzz \(Open77.database.zzz\) is not in the catalogue/.test(m)), findings.join("\n")); + assert.ok(findings.some((m) => /^warning server\/main.lua:3 Open77.chat.send.await: the server card of Open77.chat.send documents no .await form/.test(m)), findings.join("\n")); + assert.ok(findings.some((m) => /^error client\/main.lua:1 MySQL.query.await \(Open77.database.query\) is a server native used in a client script/.test(m)), findings.join("\n")); + + // On a build older than the database table the alias resolves and the since check speaks. + const old = await validateResource(bank, await context("2.31.13+op77.44")); + assert.ok(old.some((f) => /MySQL.query.await \(Open77.database.query\) \(server\) needs 2.31.13\+op77.45/.test(f.message)), old.map((f) => f.message).join("\n")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("open77_api takes client: as the mirror of server:", async () => { + const ctx = await context(); + const both = ctx.index.cards.find((c) => c.runtime === "server" && ctx.index.cards.some((o) => o.runtime === "client" && o.qualified === c.qualified))!; + const { ask, close } = await mcpClient(ctx); + try { + assert.match(await ask("open77_api", { name: `client:${both.qualified}` }), /^# client /); + assert.doesNotMatch(await ask("open77_api", { name: `client:${both.qualified}` }), /\n# server /); + assert.match(await ask("open77_api", { name: `server:${both.qualified}` }), /^# server /); + assert.match(await ask("open77_api", { name: `CLIENT:${both.qualified}` }), /^# client /, "the prefix is case-insensitive"); + assert.match(await ask("open77_api", { name: `client:${both.qualified}`, runtime: "client" }), /^# client /); + assert.match(await ask("open77_api", { name: `client:${both.qualified}`, runtime: "server" }), /names the client side but runtime=server/); + assert.match(await ask("open77_api", { name: "client:NoSuchNative" }), /^No native named client:NoSuchNative/); + } finally { + await close(); + } +}); + +test("open77_api answers server:exports and server:print from the guides", async () => { + const ctx = await context(); + const { ask, close } = await mcpClient(ctx); + try { + const exportsCard = await ask("open77_api", { name: "server:exports" }); + assert.match(exportsCard, /^# server exports\(name: string, fn: function\)/); + assert.match(exportsCard, /server-exports#publish-a-service/); + assert.match(exportsCard, /exports\('add', function\(playerId, points\)/, "the guide's worked example is inlined"); + const printCard = await ask("open77_api", { name: "server:print" }); + assert.match(printCard, /^# server print\(/); + assert.match(printCard, /server-api#logging/); + // The bare name renders the client card and the server pointer together. + const bare = await ask("open77_api", { name: "exports" }); + assert.match(bare, /^# client exports\(/); + assert.match(bare, /\n# server exports\(name: string, fn: function\)/); + assert.doesNotMatch(await ask("open77_api", { name: "exports", runtime: "client" }), /\n# server exports/); + // The listing says the two exist. + const listing = await ask("open77_namespace", { namespace: "_G", runtime: "server" }); + assert.match(listing, /documented in the guides only.*server exports\(name: string, fn: function\).*server print\(/); + assert.doesNotMatch(await ask("open77_namespace", { namespace: "_G", runtime: "client" }), /documented in the guides only/); + } finally { + await close(); + } +}); + +test("validate warns when a command name is already registered by a resource the server loads", async () => { + const ctx = await context(); + const root = await mkdtemp(path.join(os.tmpdir(), "open77-cmd-")); + try { + const resources = path.join(root, "resources"); + const write = async (name: string, manifest: string, files: Record) => { + const dir = path.join(resources, name); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "open77.lua"), manifest, "utf8"); + for (const [file, body] of Object.entries(files)) { + await mkdir(path.dirname(path.join(dir, file)), { recursive: true }); + await writeFile(path.join(dir, file), body, "utf8"); + } + return dir; + }; + await write("open77_medic", 'resource "open77_medic"\nserver_script "server.lua"\nclient_script "client.lua"\n', { + "server.lua": 'RegisterCommand("heal", function(source) end, true)\n-- RegisterCommand("ghost", function() end)\nRegisterCommand(\'revive\', function(source) end, true)\n', + "client.lua": 'Open77.runtime.registerCommand("radio", function() end)\n', + }); + await write("shared_tools", 'resource "shared_tools"\nshared_script "shared/*.lua"\n', { + "shared/cmds.lua": 'RegisterCommand("ping", function() end)\n', + }); + const mine = await write("my_rp", 'resource "my_rp"\nserver_script "server/main.lua"\nclient_script "client/main.lua"\n', { + "server/main.lua": 'RegisterCommand("heal", function(source) end)\nRegisterCommand("radio", function(source) end)\nRegisterCommand("ping", function(source) end)\nRegisterCommand("ghost", function(source) end)\nRegisterCommand("mine", function(source) end)\n', + "client/main.lua": 'RegisterCommand("radio", function() end)\nRegisterCommand("revive", function() end)\n', + }); + await writeFile(path.join(root, "server.jsonc"), '{ "resources": { "root": "./resources" } }', "utf8"); + + const registered = await registeredCommands(path.join(resources, "open77_medic"), ctx.index.manifestSchema.directives); + assert.deepEqual(registered.map((c) => `${c.side}:${c.name}@${c.file}:${c.line}`), ["client:radio@client.lua:1", "server:heal@server.lua:1", "server:revive@server.lua:3"], "commented-out registrations are skipped"); + + // What open77_validate does: the workspace's resources become the loaded set. + const workspace = await detectWorkspace(mine); + assert.deepEqual(workspace.resources, ["my_rp", "open77_medic", "shared_tools"]); + const loaded = (await Promise.all(workspace.resources.map((n) => findResourceDir(workspace, n)))).filter((d): d is string => d !== null); + const findings = await validateResource(mine, ctx, { loadedResources: loaded }); + const warnings = findings.filter((f) => f.severity === "warning" && /already registered/.test(f.message)).map((f) => `${f.file}:${f.line} ${f.message}`); + assert.deepEqual(warnings.sort(), [ + "client/main.lua:1 command /radio is already registered by open77_medic (client.lua:1, client side); the runtime keeps one handler and never says which", + "server/main.lua:1 command /heal is already registered by open77_medic (server.lua:1, server side); the runtime keeps one handler and never says which", + "server/main.lua:3 command /ping is already registered by shared_tools (shared/cmds.lua:1, shared side); the runtime keeps one handler and never says which", + ].sort()); + assert.ok(!warnings.some((w) => /\/revive/.test(w)), "a client command next to another resource's server command is not a collision"); + assert.ok(!warnings.some((w) => /\/ghost|\/mine/.test(w)), "a commented-out or unique name is not a collision"); + assert.ok(!warnings.some((w) => /server\/main.lua:2/.test(w)), "the server /radio does not collide with a client /radio"); + assert.ok(findings.every((f) => !(f.severity === "error" && /already registered/.test(f.message))), "collisions are warnings, never errors"); + + // Without a loaded set (no server next to the session) the check is silent. + assert.deepEqual(await commandCollisions(mine, [], ctx.index.manifestSchema.directives), []); + assert.ok(!(await validateResource(mine, ctx)).some((f) => /already registered/.test(f.message))); + // The validated resource is never compared with itself. + assert.deepEqual(await commandCollisions(mine, [mine], ctx.index.manifestSchema.directives), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +});