Skip to content

feat(schema): add package executable to select an npm bin - #1672

Open
zero-abd wants to merge 1 commit into
modelcontextprotocol:mainfrom
zero-abd:feat/npm-package-bin
Open

zero-abd wants to merge 1 commit into
modelcontextprotocol:mainfrom
zero-abd:feat/npm-package-bin

Conversation

@zero-abd

Copy link
Copy Markdown

Summary

Adds an optional executable field to Package so a server.json entry can say which of an npm package's bin executables starts the MCP server. Without it, npx -y <identifier>@<version> fails with could not determine executable to run whenever a package ships several bins and none is named after the package. Refs #1629. The design and the data behind it are also posted on the issue.

Problem

npx picks a bin in one of two cases: the package has exactly one bin, or one bin matches the unscoped package name. Otherwise it errors. I scanned the latest version of every server in the registry and checked each npm version manifest against that rule:

npm package entries (latest versions) count
single bin 8,932
bin named after the package 809
several bins, none named after the package (npx fails) 137
no bin at all 253
manifest fetch failed 107
total 10,238

A live example is io.github.hellocoop/admin-mcp → @hellocoop/admin-mcp@1.5.7, with bins mcp and hello-mcp-http:

$ npx -y @hellocoop/admin-mcp@1.5.7
npm error could not determine executable to run

12 of the 137 work around this through runtimeArguments (e.g. ["-y", "-p", "@circulara/plugin", "circulara-mcp"]). That spec is unpinned and depends on how each client orders arguments around <identifier>@<version>.

Changes

  • docs/reference/api/openapi.yaml: new Package.executable property: string, 1 to 255 chars, pattern ^[A-Za-z0-9_][A-Za-z0-9._-]*$. The description says npm-only and gives the client command.
  • docs/reference/server-json/draft/server.schema.json: regenerated with make generate-schema.
  • pkg/model/types.go: Executable field with matching huma tags, so /v0/publish rejects a malformed value with 422 before validation runs.
  • internal/validators/validators.go, constants.go: semantic check validatePackageExecutable. It rejects the field on non-npm packages (package-executable-unsupported) and rejects values that fail the pattern or length (package-executable-invalid). This also covers $schema versions that predate the field.
  • internal/validators/registries/npm.go: NPMPackageResponse now decodes bin. When executable is set, it must be a key of bin in the published version. The string shorthand counts as one bin named after the unscoped package. The existing version-manifest GET is reused, so there is no extra request. The error lists the available bins.
  • Docs: a CHANGELOG "Draft (Unreleased) / Added" entry, a generic-server-json.md example (the example count in tools/validate-examples goes 17 to 18), and a "Packages with multiple executables" section in package-types.mdx.

Client behavior

With executable set, a client runs:

npx -y --package=<identifier>@<version> -- <executable> [packageArguments]

Checked locally with npm 12.0.2: npx -y --package=typescript@5.6.2 -- tsc --version prints Version 5.6.2, and npx -y typescript@5.6.2 --version fails with could not determine executable to run. Clients that don't know the field ignore it and behave as today.

Testing

New tests:

  • TestValidate_PackageExecutable (validators): valid names, plus rejection of -c, ../bin/server, bin/server, mcp server, mcp;rm, .mcp, a 256-char name, and the field on pypi and oci. It also asserts the issue path is packages[0].executable.
  • TestValidateNPM_Executable (registries, httptest mock): selected bin present; bin absent (error lists sumlyzer, sumlyzer-mcp-server); string shorthand on a scoped package; no bin; malformed bin; no executable means no bin check.
  • TestValidateNPM_RealPackages: two live cases on @hellocoop/admin-mcp@1.5.7. mcp passes, and admin-mcp fails with (available: hello-mcp-http, mcp).
  • TestPublishEndpoint_PackageExecutable (handlers, Postgres): the field is stored and echoed on publish, --call=evil gets 422 from the pattern, and pypi plus executable gets 422.
  • TestValidateEndpoint: /v0/validate reports packages[0].executable as a semantic issue for pypi.

Before the implementation (tests only, with the validators.go/npm.go changes reverted), 16 subtests fail across those five tests. After:

$ go test -count=1 -p 4 -race ./internal/... ./cmd/... ./pkg/...
ok  	github.com/modelcontextprotocol/registry/internal/api
ok  	github.com/modelcontextprotocol/registry/internal/api/handlers/v0
ok  	github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth
ok  	github.com/modelcontextprotocol/registry/internal/api/router
ok  	github.com/modelcontextprotocol/registry/internal/auth
ok  	github.com/modelcontextprotocol/registry/internal/database
ok  	github.com/modelcontextprotocol/registry/internal/importer
ok  	github.com/modelcontextprotocol/registry/internal/service
ok  	github.com/modelcontextprotocol/registry/internal/telemetry
ok  	github.com/modelcontextprotocol/registry/internal/validators
ok  	github.com/modelcontextprotocol/registry/internal/validators/registries
ok  	github.com/modelcontextprotocol/registry/cmd/publisher/auth
ok  	github.com/modelcontextprotocol/registry/cmd/publisher/commands
$ golangci-lint run --timeout=5m     # v2.13.1, same as CI
0 issues.
$ make check-schema
✓ server.schema.json is in sync with openapi.yaml
$ ./tools/validate-examples.sh
All validations passed!

The DB-backed packages ran against a local PostgreSQL 16 (conda-forge build, since Docker isn't available on this machine) on localhost:5432 with the mcpregistry credentials from testutil.go. I did not run tests/integration/run.sh, which needs Docker.

I also checked the draft schema directly with a JSON Schema validator. It accepts sumlyzer-mcp-server and _x.y-2, and rejects -c, ../x, a/b, a b, .x, the empty string and a 256-char value. All 349 bin names from the 137 affected packages match the pattern.

Notes for reviewers

  • Name: I chose executable because it reads the same for other registry types. bin would match npm and cargo vocabulary. Renaming is mechanical.
  • npm-only for now: rejecting the field elsewhere keeps its meaning defined. It could extend to PyPI console scripts (uvx --from <pkg>==<ver> <script>) and cargo (crates.io returns bin_names per version) in follow-ups.
  • Released schemas: Package has no additionalProperties: false in 2025-12-11, so publishers can send the field before the next schema release. The Go semantic check enforces the same rules either way. If you'd rather gate it on the next schema version, I can add that.
  • Out of scope: this PR does not warn about the 137 existing ambiguous entries or about packages with no bin. A publish-time warning for "npx cannot resolve a bin" could be a follow-up.

AI assistance: this PR was written with Claude Code (design, implementation, tests and the registry scan). I reviewed the changes and ran the tests and commands shown above.

🤖 Generated with Claude Code

An npm package can declare several executables in package.json "bin".
`npx <identifier>@<version>` only works when the package has one bin or
one named after the package; otherwise npx exits with "could not
determine executable to run", and server.json had no way to say which
bin starts the MCP server.

Add an optional `executable` field to Package:

- openapi.yaml and the generated draft schema: string, 1-255 chars,
  pattern ^[A-Za-z0-9_][A-Za-z0-9._-]*$ so the value can never be read
  as a path or a flag by a package runner.
- Semantic validation: reject a malformed value, and reject the field on
  non-npm packages until other registry types define its meaning.
- npm registry validation: the selected executable must be an entry of
  "bin" in the published version (object form, or the string shorthand
  that names the unscoped package).
- Docs: CHANGELOG draft entry, a generic-server-json example, and a
  package-types section showing `npx -y --package=<id>@<ver> -- <exe>`.

Refs modelcontextprotocol#1629

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@FelixFangX FelixFangX left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against lakeboard-converter, which ships three bins: lakeboard-converter and mindmap-converter (both the CLI) and lakeboard-converter-mcp (the server). One bin is named after the package, so npx doesn't fail, but it starts the CLI. Its server.json works around that with a positional mcp in packageArguments. With npm 11.21.0, npx -y lakeboard-converter@0.4.1 mcp and npx -y --package=lakeboard-converter@0.4.1 -- lakeboard-converter-mcp return the same initialize result, so executable covers this case too and makes the subcommand unnecessary. go test ./internal/validators/... ./pkg/... passes on 822b374.

Possible follow-up: mcp-publisher init (cmd/publisher/commands/init.go, the npm branch of the package switch) doesn't look at bin, so it can't suggest executable for multi-bin packages.


### Packages with multiple executables

If your package declares more than one executable in `package.json` `bin` and none of them is named after the package, `npx <package>` fails with "could not determine executable to run". Set `executable` to the `bin` entry that starts your MCP server:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also useful when a bin is named after the package but isn't the server, e.g. a CLI with a separate <name>-mcp bin. Those packages currently need a subcommand in packageArguments. Maybe something like: "If your package declares more than one executable in bin, set executable to the one that starts your MCP server. It's required when none of them is named after the package, since npx <package> then fails with ..."


### npm package with multiple executables

When an npm package declares several executables in its `package.json` `bin` field, `npx <package>` only works if one of them is named after the package. Use `executable` to name the one that starts the MCP server. The registry checks that it is an entry of `bin` in the published version, and clients run it with `npx -y --package=<identifier>@<version> -- <executable>`, followed by any `packageArguments`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth saying where runtimeArguments go when executable is set, e.g. npx <runtimeArguments> --package=<identifier>@<version> -- <executable> <packageArguments>. npx -y --prefer-online --package=lakeboard-converter@0.4.1 -- lakeboard-converter-mcp works with npm 11.21.0.

The form is also npx-specific. pnpm dlx --package=lakeboard-converter@0.4.1 -- lakeboard-converter-mcp works, but bunx 1.4.2 rejects every variant I tried (--package=x -- bin gives Script not found "lakeboard-converter-mcp", -p x bin tries to eval the spec). Clients that map runtimeHint to another runner would need their own translation, so it may help to say that.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants