diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 80b0510..1393b54 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -13,7 +13,8 @@ "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" - } + }, + "category": "Developer Tools" } ] } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0ea74e1..f8d0de0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -12,14 +12,17 @@ "license": "MIT", "keywords": ["deploy", "cloud", "hosting", "containers", "ai", "mcp", "infrastructure"], "skills": "./skills/", - "mcpServers": "./mcp.json", + "mcpServers": "./.mcp.json", "interface": { "displayName": "Nexlayer", "shortDescription": "Ship containerized apps to a live URL", "longDescription": "Nexlayer turns a repo into a running production app: generate the Dockerfile, build and push the image, write and validate nexlayer.yaml, deploy, and hand back a live URL. Includes debugging for what you shipped — logs, events, shells, and live database queries.", "developerName": "Nexlayer", + "category": "Developer Tools", "capabilities": ["Read", "Write"], "websiteURL": "https://nexlayer.com", + "privacyPolicyURL": "https://nexlayer.com/legal/privacy", + "termsOfServiceURL": "https://nexlayer.com/legal/terms", "brandColor": "#21B7CB", "logo": "./assets/logo.png", "composerIcon": "./assets/logo.png", diff --git a/.mcp.json b/.mcp.json index 25e3d16..ecb70bc 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,5 +1,4 @@ { - "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { "nexlayer-mcp": { "type": "streamable-http", diff --git a/README.md b/README.md index d82c57d..bd0bfca 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,13 @@ The bundle is also tested against the production MCP server, not just the source ``` plugin.json Agent Plugins 1.0 manifest (portable core) -mcp.json Nexlayer MCP server +mcp.json Portable Agent Plugins MCP server config +.mcp.json Codex MCP server config skills/ ship-it-nexlayer, debug-nexlayer (verbatim from the MCP repo) commands/ agents/ rules/ Client extensions — thin wrappers over the skills .cursor-plugin/plugin.json Cursor manifest .claude-plugin/ Claude Code manifest and marketplace entry -.codex-plugin/plugin.json Codex manifest and listing metadata +.codex-plugin/plugin.json Codex manifest, MCP pointer, and listing metadata .agents/plugins/ Codex marketplace entry hooks/ nexlayer.yaml checker + per-host hook config com.github.copilot/ Copilot namespace (generated mirror of agents/) diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 18b1971..6d3246f 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -4,7 +4,7 @@ One repository, one payload, thin per-host manifests. ## Why one repo, not one per IDE -Since [Agent Plugins 1.0](https://agent-plugins.org/specification) (August 2026) the portable unit is a directory with `plugin.json`, `skills/`, and `mcp.json`. Cursor, Codex, VS Code, and Copilot read it directly. Claude Code and Grok read a manifest at a different path but load the *same* `skills/`, `commands/`, and `agents/`. +Since [Agent Plugins 1.0](https://agent-plugins.org/specification) (August 2026) the portable unit is a directory with `plugin.json`, `skills/`, and `mcp.json`. VS Code, Copilot, and Devin fallback read it directly. Cursor, Codex, Claude Code, and Grok read a manifest at a host-specific path but load the *same* `skills/`, `commands/`, and `agents/`. So the per-host difference is three small JSON files, not three codebases: @@ -16,7 +16,7 @@ plugin.json ← Agent Plugins clients (VS Code, Copilot, Kiro, .devin-plugin/plugin.json ← Devin CLI: skills + MCP + subagents com.github.copilot/ ← Copilot namespace: agents (the only place VS Code reads them) ↓ all of them point at: -skills/ commands/ agents/ rules/ hooks/ mcp.json +skills/ commands/ agents/ rules/ hooks/ mcp.json .mcp.json ``` Each host manifest is 20-30 lines of metadata pointing at the same directories. `scripts/validate.py` fails if their `name`/`version` disagree or any path they name is missing, so they cannot drift apart quietly. @@ -89,7 +89,7 @@ Fix skill content upstream, never here — except through `patches/`, which carr ## Endpoint -`mcp.json` points at `https://mcp.nexlayer.ai/api/mcp` under the server key `nexlayer-mcp`, matching both `skills/ship-it-nexlayer/references/MCP-SETUP.md` and the public setup docs at [nexlayer.com/docs/mcp](https://nexlayer.com/docs/mcp/overview). The server's own endpoint table lists `/mcp` as primary and `/api/mcp` as a legacy alias; both answer `initialize` today. If `/mcp` becomes the only supported path, change it in both places at once. +`mcp.json` and `.mcp.json` point at `https://mcp.nexlayer.ai/api/mcp` under the server key `nexlayer-mcp`, matching both `skills/ship-it-nexlayer/references/MCP-SETUP.md` and the public setup docs at [nexlayer.com/docs/mcp](https://nexlayer.com/docs/mcp/overview). The server's own endpoint table lists `/mcp` as primary and `/api/mcp` as a legacy alias; both answer `initialize` today. If `/mcp` becomes the only supported path, change it in both places at once. ## Publishing @@ -120,13 +120,13 @@ Keep the two tracks straight: the connector makes the *tools* discoverable; this ### Codex -`.codex-plugin/plugin.json` carries the listing metadata (display name, brand color, logo, default prompts) and `.agents/plugins/marketplace.json` makes the repo installable: +`.codex-plugin/plugin.json` carries the listing metadata (display name, category, brand color, legal URLs, logo, default prompts) and `.agents/plugins/marketplace.json` makes the repo installable: ```bash codex plugin marketplace add Nexlayer/nexlayer-plugin ``` -Untested against a live Codex install — the marketplace `source.path` is `./` because the plugin sits at the repo root, and `interface.category` is deliberately unset until we know the accepted values. +The marketplace `source.path` is `./` because the plugin sits at the repo root. This layout was tested with `codex plugin marketplace add ` and `codex plugin add nexlayer@nexlayer`; Codex resolved the plugin source to the repository root. ### VS Code / Copilot diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 481d7bb..74eac74 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -105,7 +105,7 @@ Reconciled in `patches/0001`: | Windsurf / Cline | no transport / `sse` | `http` | `http`. | | Dashboard | `app.nexlayer.io` | not stated | `app.nexlayer.com` (see §7). | -`mcp.json` in this plugin registers the server as `nexlayer-mcp` — same name the docs and the skill use — over `streamable-http`, which is the Agent Plugins spelling of the same HTTP transport. +`mcp.json` and `.mcp.json` in this plugin register the server as `nexlayer-mcp` — same name the docs and the skill use — over `streamable-http`, which is the Agent Plugins spelling of the same HTTP transport. The root `mcp.json` keeps the portable Agent Plugins schema marker; Codex uses `.mcp.json`, which omits `$schema` because the Codex plugin validator treats bundled MCP config as a pure server map. Left alone, and worth a look on the website side: @@ -118,12 +118,17 @@ Left alone, and worth a look on the website side: |------|--------| | `claude plugin validate . --strict` (plugin manifest) | ✔ Validation passed | | `claude plugin validate . --strict` (marketplace manifest) | ✔ Validation passed | +| `python3 /Users/salstagroup/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py ` | ✔ Validation passed | +| `codex plugin marketplace add ` in an isolated `CODEX_HOME` | ✔ Marketplace resolved as `nexlayer` | +| `codex plugin add nexlayer@nexlayer` in an isolated `CODEX_HOME` | ✔ Installed and enabled | | `python3 scripts/validate.py` | PASS | | `scripts/sync-from-mcp.sh --check` | in sync, patch applies | | `scripts/gen-host-components.py --check` | mirrors in sync | Anthropic's review pipeline runs `claude plugin validate` on every submission, so this is the same check, not an approximation of it. +Unauthenticated MCP endpoint checks also passed: `https://mcp.nexlayer.ai/api/mcp` returns `401` with `WWW-Authenticate` pointing to `https://mcp.nexlayer.ai/.well-known/oauth-protected-resource`, and the protected-resource and OAuth authorization-server metadata documents resolve. Authenticated tool scanning still requires a Nexlayer account token. + ## 10. Hook behavior `hooks/nexlayer-yaml-check.py` was exercised against every payload shape it will meet: diff --git a/scripts/validate.py b/scripts/validate.py index 0e9b36d..45169d2 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -211,6 +211,98 @@ def check_host_manifests() -> None: fail(f".codex-plugin/plugin.json: interface.{key} points at missing path {value}") +def check_codex_packaging() -> None: + """Mirror the Codex plugin ingestion checks that are easy to regress.""" + manifest = load_json(ROOT / ".codex-plugin/plugin.json") or {} + if not manifest: + return + + if manifest.get("mcpServers") != "./.mcp.json": + fail(".codex-plugin/plugin.json: mcpServers must point at ./.mcp.json") + + interface = manifest.get("interface") + if not isinstance(interface, dict): + fail(".codex-plugin/plugin.json: interface must be an object") + else: + for field in ( + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + ): + if not isinstance(interface.get(field), str) or not interface[field].strip(): + fail(f".codex-plugin/plugin.json: interface.{field} is required for Codex") + prompts = interface.get("defaultPrompt") + if not isinstance(prompts, list) or not any( + isinstance(prompt, str) and prompt.strip() for prompt in prompts + ): + fail(".codex-plugin/plugin.json: interface.defaultPrompt must include at least one prompt") + capabilities = interface.get("capabilities") + if not isinstance(capabilities, list) or not all( + isinstance(capability, str) and capability.strip() for capability in capabilities + ): + fail(".codex-plugin/plugin.json: interface.capabilities must be a non-empty string array") + for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"): + value = interface.get(field) + if value is not None and not (isinstance(value, str) and value.startswith("https://")): + fail(f".codex-plugin/plugin.json: interface.{field} must be an https URL") + + mcp = load_json(ROOT / ".mcp.json") + if mcp is not None: + extras = set(mcp) - {"mcpServers"} + for key in sorted(extras): + fail(f".mcp.json: field {key!r} is not accepted by Codex plugin validation") + servers = mcp.get("mcpServers") + if not isinstance(servers, dict) or not servers: + fail(".mcp.json: mcpServers must be a non-empty object") + else: + for name, server in sorted(servers.items()): + if not isinstance(server, dict): + fail(f".mcp.json: server {name!r} must be an object") + continue + if server.get("type") != "streamable-http": + fail(f".mcp.json: server {name!r} must use streamable-http") + url = server.get("url") + if not isinstance(url, str) or not url.startswith("https://"): + fail(f".mcp.json: server {name!r} must use an https URL") + + marketplace = load_json(ROOT / ".agents/plugins/marketplace.json") or {} + if not marketplace: + return + entries = marketplace.get("plugins") + if not isinstance(entries, list): + fail(".agents/plugins/marketplace.json: plugins must be an array") + return + plugin_name = manifest.get("name") + matching = [entry for entry in entries if isinstance(entry, dict) and entry.get("name") == plugin_name] + if not matching: + fail(f".agents/plugins/marketplace.json: no entry for {plugin_name!r}") + return + entry = matching[0] + if not isinstance(entry.get("category"), str) or not entry["category"].strip(): + fail(".agents/plugins/marketplace.json: plugin entry must include category") + policy = entry.get("policy") + if not isinstance(policy, dict): + fail(".agents/plugins/marketplace.json: plugin entry must include policy") + else: + if policy.get("installation") not in {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"}: + fail(".agents/plugins/marketplace.json: policy.installation is invalid") + if policy.get("authentication") not in {"ON_INSTALL", "ON_USE"}: + fail(".agents/plugins/marketplace.json: policy.authentication is invalid") + source = entry.get("source") + if not isinstance(source, dict): + fail(".agents/plugins/marketplace.json: plugin entry must include source") + else: + rel = source.get("path") + if source.get("source") != "local": + fail(".agents/plugins/marketplace.json: source.source must be local") + if not isinstance(rel, str) or not rel.startswith("./") or ".." in Path(rel).parts: + fail(".agents/plugins/marketplace.json: source.path must be a ./-prefixed in-tree path") + elif not (ROOT / rel[2:]).is_dir(): + fail(f".agents/plugins/marketplace.json: source.path points at missing directory {rel}") + + def check_generated_mirrors() -> None: """Host-namespace copies must match the canonical component they mirror.""" for src in sorted((ROOT / "agents").glob("*.md")): @@ -256,8 +348,13 @@ def check_mcp_discovery() -> None: if not dotted.is_file(): fail(".mcp.json missing — Claude Code loads no MCP server without it") return - if plain.read_text() != dotted.read_text(): - fail(".mcp.json and mcp.json disagree — copy mcp.json to .mcp.json") + # Not a byte comparison: Codex's plugin validator rejects any key but + # `mcpServers` in .mcp.json, while `mcp.json` carries the Agent Plugins + # `$schema`. Compare the server map, which is the part that must not drift. + a = (load_json(plain) or {}).get("mcpServers") + b = (load_json(dotted) or {}).get("mcpServers") + if a != b: + fail(".mcp.json and mcp.json declare different mcpServers") # Same method, same surprise: Claude Code silently drops a hook whose `command` @@ -292,6 +389,7 @@ def main() -> int: check_links() check_tool_names() check_host_manifests() + check_codex_packaging() if problems: print(f"FAIL — {len(problems)} problem(s):\n")