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
3 changes: 2 additions & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
}
},
"category": "Developer Tools"
}
]
}
5 changes: 4 additions & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"nexlayer-mcp": {
"type": "streamable-http",
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
10 changes: 5 additions & 5 deletions docs/PLATFORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <repo-path>` and `codex plugin add nexlayer@nexlayer`; Codex resolved the plugin source to the repository root.

### VS Code / Copilot

Expand Down
7 changes: 6 additions & 1 deletion docs/VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 <repo>` | ✔ Validation passed |
| `codex plugin marketplace add <repo-path>` 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:
Expand Down
102 changes: 100 additions & 2 deletions scripts/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")):
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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")
Expand Down
Loading