Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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:<name>` works as the mirror of `server:<name>`; 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("<name>")` 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<what you need>" cards, guides, events, permissions, FiveM aliases
open77_api <name> the card: runtime, permissions, since, reasons, example
open77_api <name> the card: runtime, permissions, since, reasons, example (server:<name> / client:<name> pins a side)
open77_guide <slug|slug#section> the how-to behind the card (a search ref pastes as is)
open77_manifest_schema before writing open77.lua
write
open77_validate <resource> (local MCP) syntax, manifest, unknown natives, wrong side, permissions, build
open77_validate <resource> (local MCP) syntax, manifest, unknown natives, wrong side, permissions, build, command names taken by another resource
open77_resource reload <name> (local MCP, through Warden) then open77_console_tail for the result
```

Expand Down
117 changes: 117 additions & 0 deletions src/index/conventions.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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>): 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}`));
}
70 changes: 56 additions & 14 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -154,20 +155,25 @@ 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"),
},
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"));
},
);

Expand Down Expand Up @@ -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"));
},
);

Expand Down Expand Up @@ -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.`,
Expand Down
8 changes: 6 additions & 2 deletions src/workspace/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,19 @@ 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 },
},
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
Expand Down
Loading
Loading