Skip to content

Docs: quickstart MCP configs use unpinned npx -y, contradicting the Local Server Security guide #3431

Open

Description

@basitalisandhu

What's broken?

The documentation is wrong or misleading

Where in the spec or docs?

https://modelcontextprotocol.io/docs/develop/connect-local-servers and https://modelcontextprotocol.io/examples

What should happen?

The beginner examples should follow the Local Server Security guide, which says to "Install a specific version rather than latest". For example "args": ["-y", "@modelcontextprotocol/[email protected]", ...], plus one sentence linking to that guide.

What actually happens?

The pages people copy from start every server unpinned: docs/develop/connect-local-servers.mdx (lines 100, 116, 216, 220, 273), docs/examples.mdx (lines 43, 50, 66 to 78), docs/develop/build-client.mdx (961) and docs/tools/debugging.mdx (131), in both the 2026-07-28 and draft versions. Each launch can fetch whatever was published last.

The pattern is widespread. In a random sample of 2,999 public GitHub repositories that ship agent skills or MCP configs, 159 of the 346 repositories that configure a local MCP server (46.0%) start at least one with no pinned version, and 63 of 346 (18.2%) ask for latest outright. A hand check found 19 of 22 findings correct. Caveat: it is a sample of what GitHub code search surfaces, not a census. Data and method: https://github.com/basitalisandhu/agent-skills-state-2026

Anything else?

The registry already rejects latest and version ranges for server.json packages (internal/validators/validators.go), so these docs are the main place that still teaches the unpinned form. If maintainers agree, I am happy to open the docs PR.

AI disclosure: I used Claude Code to help prepare the analysis and draft this issue.

Activity

  1. sattyamjjain commented on Oct 13, 2026

    @sattyamjjain
  2. Ben-Home commented on Oct 13, 2026

    @Ben-Home
  3. imran-siddique commented on Oct 15, 2026

    @imran-siddique

    On the range question, I tested it. npm 11.16.0 against the live registry, server-filesystem:

    ^1.0.0  >=1.0.0  >=0.6.0  *  latest     -> 1.0.0
    ^2026.7.4  ~2026.8.0  >=2026.0.0  >1.0.0 -> 2026.8.31 (deprecated)
    

    npm takes the latest tag when it satisfies the range, so the proposed @1.0.0 example is safe, and so is ^1.0.0.

    The exposure is anyone who wrote a date-based range before 11 October. npm install prints a deprecation warning and exits 0, npm outdated shows Wanted 2026.8.31, Latest 1.0.0, and npm update leaves them on 2026.8.31.

    For the PR: mcp-server-git, -fetch and -time are still date-based on PyPI (2026.10.10), so a pinning example needs both formats.

  4. basitalisandhu commented on Oct 16, 2026

    @basitalisandhu
    Author

    Thanks, both. Rechecked today (16 Oct): npm latest for @modelcontextprotocol/server-filesystem is still 1.0.0 (published 11 Oct), and mcp-server-git, mcp-server-fetch and mcp-server-time are at 2026.10.10 on PyPI.

    So the docs change would use exact pins in both formats, for example @modelcontextprotocol/[email protected] with npx -y and [email protected] with uvx, no ranges, plus the guide's sentence that a launcher pin fixes only the top-level package, linking to the image digest option.

  5. Paraphern commented on Oct 17, 2026

    @Paraphern

    Field data for @sattyamjjain’s transitive dependency point: we pinned and diffed 24 popular MCP servers over two months, and the dependency-drift case is real and measurable. [email protected] declares a floating zod: ^4.4.3 range - the same 2.1.2 tarball, installed on different days, served different numCandidates typing (integer with bounds vs plain number) depending on which in-range zod minor resolved. The tool contract is a function of (server version, resolved dependency versions), not server version alone. We track this as an open item for the next pin format revision (recording resolved dependency versions alongside each captured contract).

    On the broader point: pinning the package version is necessary but not sufficient. Even with an exact pin, the surface the server actually serves via tools/list can change between sessions (remote config, per-client user-agent routing, feature flags). A version pin tells you what you installed; a contract pin (hash over each tool’s {name, description, inputSchema}) tells you what the agent is actually working against. Both are needed.

    Happy to share the drift corpus if it helps the docs PR cite concrete numbers.

  6. AbarnaaSree commented on Oct 18, 2026

    @AbarnaaSree

    Thanks for the response! I’ll investigate the Windows TUI input pipeline to understand how Shift+Enter and Ctrl+J are translated under MSYS2 UCRT64/mintty, and whether the original key combinations can be distinguished before normalization. I’ll use your patch and regression tests as a reference, with particular attention to preserving intentional Ctrl+Enter and Ctrl+Shift+Enter behavior. I’ll share my diagnostic findings here before proposing a targeted fix. If you can share the local patch and tests, that would be a helpful starting point.

  7. basitalisandhu commented on Oct 18, 2026

    @basitalisandhu
    Author

    @Paraphern thank you, the mongodb-mcp-server 2.1.2 case is a concrete example of the transitive point: the same exact pin served two different tool schemas depending on which zod minor resolved. It strengthens the proposal because it shows the docs should present an exact package pin as the minimum, with a lockfile or a recorded tool-contract hash as the way to catch what the pin alone misses. If you can share the drift corpus, a single cited number would make that sentence in the docs change much easier to accept.

  8. Paraphern commented on Oct 18, 2026

    @Paraphern

    Happy to. The corpus is public and machine-readable:

    The single number for the docs sentence: 25 of 28 watched npm-distributed MCP servers showed at least one tool-contract change across their verified version windows (verification current as of October 2026). Three stayed byte-stable across their watched windows - and a fourth, Notion, was byte-stable on its recent 2.4.1 -> 2.5.2 window while drifting across the 1.0.0 -> 2.5.2 arc, which is the same distinction a lockfile makes visible. Thirteen windows included schema-level tightenings or loosenings (graded BREAKING or LOOSENED). Every number traces to a linked page with the window and a repro command, so the citation does not have to take our word for it.

    For the transitive sentence specifically, the mongodb-mcp-server 2.1.2 case remains the sharpest single example: same tarball, different numCandidates typing depending on the resolved zod minor. "A lockfile or a recorded tool-contract hash catches what the pin alone misses" is exactly the boundary we measured.

  9. basitalisandhu commented on Oct 18, 2026

    @basitalisandhu
    Author

    @Paraphern thank you for the corpus; it is now linked from the study's README as a related dataset (basitalisandhu/agent-skills-state-2026#10). The observation that bears most on this proposal is the mongodb-mcp-server 2.1.2 page: the same exact package pin served a different numCandidates type depending on the resolved zod minor, so the docs should show the exact version pin and also point to a lockfile or a recorded tool-contract hash, the two formats of pin your data shows catching different things. That is the wording I will propose in the docs PR.

  10. basitalisandhu commented on Oct 20, 2026

    @basitalisandhu
    Author

    Some numbers that bear directly on this, from a read-only pass over 399 MCP server repositories that ship a server.json (READMEs fetched at recorded commits, nothing executed):

    • 252 of 264 READMEs with a launch command (95.5%, 95% CI 92.2 to 97.4%) never name an exact version.
    • 202 of the 212 servers that record an exact version in their own server.json (95.3%) still show unpinned commands for that same package in the README.
    • 410 of 576 npx lines (71.2%) also pass -y, and 69 of 264 READMEs (26.1%) use @latest or a refresh flag.

    So most servers copy the quickstart pattern as written. Pinned examples in the official docs would likely move the ecosystem more than anything individual maintainers do. Method, data and a hand check of 60 commands: https://github.com/basitalisandhu/agent-skills-state-2026/blob/main/docs/mcp-readme-install-pins.md

  11. alexar76 commented on Oct 21, 2026

    @alexar76

    A cited number for the "pin, and also record the tool contract" wording, from a public log we run (HISTOR). It runs every new npm/PyPI version of an MCP server in a sandbox and reads the tool list of every remote server in the registry daily.

    Between 24 September and 20 October:

    • Packages: 1,419 of 21,738 (6.5%) shipped a newer version whose tools/list differed from the previous one we ran. 1,469 of those 2,278 changes edited a tool description, 1,331 an input schema. That is the exposure of an unpinned npx -y / uvx line over four weeks, before counting the transitive drift @Paraphern documented. 26 of the packages in the rugsnare watch corpus are in the log too; 8 of them changed their tool list in that window.
    • Remote servers: 4,926 of 10,929 (45%) served a different tool list at least once. A remote server has no version a config can pin, so for those a recorded tool-contract hash is the only pin there is. It may be worth one sentence in the docs PR.
    • A caveat for any hash-based pin format: 2,719 changes changed nothing but digits in a description (a live count of tools or sources written into the description), 2,493 of them from one publisher. A strict hash trips on every one, so a pin format probably wants a field-level diff a human can read rather than a yes/no.

    Data (CC BY 4.0, one snapshot a month, every change field by field): https://doi.org/10.5281/zenodo.23512760, also on Hugging Face (alexar76/histor-mcp-tool-definitions). Method and caveats are in the dataset card: anonymous reads, a sandbox with placeholder keys, one observation per package version.

    AI disclosure: drafted with an AI assistant from our data; every number was recomputed from the published files.

  12. basitalisandhu commented on Oct 26, 2026

    @basitalisandhu
    Author

    Thanks @alexar76, that is the citable number the docs sentence was missing: 1,419 of 21,738 packages (6.5%) changed their tools/list within four weeks, which is exactly what an unpinned npx -y or uvx line picks up without anyone noticing.

    The remote-server point is fair too. There is no version to pin there, so the only thing a client can record is the tool list itself. And the 2,719 digit-only description changes are a good argument against a plain yes/no hash; a field-level diff that a person can read is the more useful check.

    For the docs change I would keep it to two sentences: use exact versions for package servers, and for remote servers note that the tool list can change between sessions and is worth reviewing when it does.

  13. alexar76 commented on Oct 26, 2026

    @alexar76

    Two sentences is a good size for a quickstart. If this saves you a round of wording, here is a version you can use:

    Pin package servers to an exact version, for example @modelcontextprotocol/[email protected] with npx -y or [email protected] with uvx, so a launch stays on that version instead of picking up whatever was published last. A remote server has no version to pin, and its tool list can change between sessions, so review the tool list again when it changes.

    If you need a source for the package numbers, they come from the October 20 snapshot: https://doi.org/10.5281/zenodo.23512760

  14. Paraphern commented on Oct 26, 2026

    @Paraphern

    A corpus data point for @alexar76's digit-only caveat, since we grade exactly that case: each pin carries a structural hash (name + inputSchema) and a separate prose hash (description, hashed as-is — we deliberately do not normalize digits out), so digit-only churn lands as a named COSMETIC finding with the WAS/NOW text rendered for a one-glance dismiss-or-escalate. The split keeps churn honest in both directions: prose churn can never hide a schema change behind one opaque mismatch, and a team that trusts a churning publisher can scope CI to schema drift only (--schema-only), which excludes prose findings from the run.

    Published example of the prose-only class: @supabase/mcp-server-supabase 0.8.3 → 0.13.0 — execute_sql and apply_migration re-worded their descriptions (safety language added), schemas byte-identical, both findings graded COSMETIC: https://github.com/Paraphern/rugsnare/blob/main/docs/servers/supabase.md

    On your log's overlap with our corpus — 26 of our watch packages appear in it, 8 changed in your window. Happy to cross-check those against our published pin snapshots (81 files, PIN-SPEC format, audits/drift-watch/pins), with one method note already on record: we profiled MTL/1 against our PIN-SPEC §3.2 surface hash and documented four deltas (outputSchema in or out of the digest, sort unit UTF-16 code units vs UTF-8 bytes, float policy, separate annotation digest), so the cross-check should compare field-level diffs, not digest equality — two correct implementations disagreeing only on sort unit would read as "someone changed something" when nothing did: https://github.com/Paraphern/rugsnare/blob/main/audits/drift-watch/comparison-dominion-captures.md

    One convergence worth naming: your separate annotation digest is the same call as our ANNOTATION finding class — annotations outside the tool-set digest, surfaced as their own change. Two implementations arriving there independently is a good sign for the docs sentence being stable.

  15. alexar76 commented on Oct 26, 2026

    @alexar76

    Cross-checked, field by field. Inputs: your pins at a8c8b3b (audits/drift-watch/pins) and our 25 October snapshot (https://huggingface.co/datasets/alexar76/histor-mcp-tool-definitions, signed manifest; toolsets.jsonl.gz sha256 6cf96e36…). Per tool I compared name, description and inputSchema, the members we both keep in full. outputSchema is left out because the pins keep only its hash. Annotations come from our tool_extras, which exist only since 22 October, so they are compared for current versions only.

    Of the 26 packages in both, HISTOR has tool lists for 21. Your pins cover 34 versions that were the npm latest while HISTOR was reading the package:

    25 are identical to a list HISTOR recorded, field by field. Annotations agree on all 318 tools of the 16 current versions where both sides have them.
    9 differ, and each has a cause:
    package, versions difference cause
    mongodb-mcp-server 3.0.5, 3.1.0 9 write tools only in HISTOR; atlas-local-connect-deployment and atlas-local-list-deployments only in the pins; connect, disconnect, list-connections and the connectionId parameter are described differently launch: your entry runs --readOnly with a connection string, HISTOR runs it bare
    @netlify/mcp 1.19.0 netlify-coding-rules only in the pin it is registered only when the server fetches its topic list at startup; HISTOR’s sandbox has no network
    @shopify/dev-mcp 1.16.0, 1.17.0 validate_component_codeblocks, validate_graphql_codeblocks only in the pins the pins carry tools the version does not serve (below)
    @currents/mcp 2.6.1, 2.7.0 currents-get-affected-executions only in the pins same
    @netlify/mcp 1.18.0, 1.20.0 netlify-coding-rules only in the pins same
    The last three rows look like a re-pin that kept tools the new version had dropped, so you may want to look at them:

    shopify: both entries are byte-identical to your 1.14.4 pin (hashes 943907…, 1be1a6…). The 1.16.0 and 1.17.0 tarballs register five tools; the module map in their dist lists feedback, learn_shopify_api, search_docs_chunks, validate and validate_theme.
    currents: the entry (hash 57a24f…) matches the 2.3.3 code, but the tool name does not occur in any file of the 2.6.1 or 2.7.0 tarballs.
    netlify: from 1.18.0 on, registerCodingContextTool always adds a reference property when it registers the tool, and registers nothing when the topic fetch comes back empty. Your 1.18.0 and 1.20.0 entries have no reference, and the 1.20.0 one is byte-identical to the 1.14.0 entry (enum []). The 1.19.0 entry has reference and 21 topics, which fits its code.
    On our side, 5 of the 26 have no list in HISTOR at all, and each is a launch problem your corpus handles with args or env: server-filesystem exits without an allowed directory (HISTOR offers no roots), azure-devops fails loading keytar (your entry passes --authentication envvar), launchdarkly exits (yours starts it with start --transport stdio --api-key …), figma-developer-mcp serves HTTP unless given --stdio, and supabase needs the network. HISTOR also did not record the package version of each observation, so for this check I matched lists to versions by content and npm publish times. That gap is closed as of today: every package observation now carries the version it ran, a change names the version before and after, and from the next weekly snapshot package_observations.jsonl.gz lists every run of a package with its version. Rows from before today stay null rather than guessed.

    One correction to the MTL/1 row of your table: the separate digest covers more than annotations. toolExtrasDigest covers every member MTL/1 drops (annotations, _meta, title, icons, execution, vendor fields), and it is SHA-256 over sorted-key JSON, not RFC 8785, because prices in _meta are fractions. Its field-level record is extras_changes: per tool, every annotation that moved as [before, after], with an escalation when readOnlyHint: true disappears or destructiveHint: true appears. That is the closest thing to your ANNOTATION class. The other three properties in the row match PROFILE.md.

    AI disclosure: the comparison and this comment were made with an AI assistant; every number comes from the two published records above.

  16. Paraphern commented on Oct 26, 2026

    @Paraphern

    Verified on our side, and the three rows you flagged were real: the 2026-10-24 automation re-pin wrote those snapshots over dirty stands, so the 1.16/1.17 shopify pins carried validate_component_codeblocks/validate_graphql_codeblocks from the 1.14.4 install, the currents 2.6.1/2.7.0 pins carried currents-get-affected-executions (whose 2.3.3-era entry matches your hash), and the netlify 1.18.0/1.20.0 netlify-coding-rules entries were byte-identical to 1.14.0. All six re-pinned from clean stands today; corrected state: shopify 1.16.0 and 1.17.0 serve exactly 5 tools (the two validators correctly GONE), currents 2.6.1/2.7.0 = 39/43 tools without the phantom, netlify 1.18.0/1.20.0 carry coding-rules with the reference property. Per-server corrections and a census erratum are up: https://github.com/Paraphern/rugsnare/blob/main/docs/servers/shopify.md

    Same artifact class as our supabase correction of 13 Oct (stale entries surviving a re-pin), and your field-level detail was precise enough to reproduce the diagnosis without guessing — that is the cross-check working exactly as intended. 25 of 34 identical field-by-field plus 318/318 annotations agreeing is the number we'll cite for "two independent captures, one contract".

    Two of your explained rows deserve a wider audience than this thread:

    • mongodb (launch leg): the same tarball serves 9 more write tools when launched bare than with our --readOnly entry, and describes connect/disconnect differently. What a server serves is a function of (version, launch args, network reachability) — a pin that records only the version verifies one leg of three. Our pins carry the launch entry (command, args, env) for exactly this reason.
    • netlify (network leg): netlify-coding-rules registers only when the startup topic fetch succeeds — your no-network sandbox and our scan saw different servers from the same version, both honestly.

    Both argue for one clause beyond the docs sentence under discussion: whatever you record, record how you launched it.

    The MTL/1 correction is accepted and folded into the comparison doc verbatim in substance (toolExtrasDigest covers annotations, _meta, title, icons, execution and vendor fields; SHA-256 over sorted-key JSON, not JCS; extras_changes with the destructive-direction escalation): https://github.com/Paraphern/rugsnare/blob/main/audits/drift-watch/comparison-dominion-captures.md

    And thank you for closing the version-attribution gap on your side — package_observations.jsonl.gz makes the next join cheaper than this one was.

  17. alexar76 commented on Oct 26, 2026

    @alexar76

    Thanks for checking and corrections

    on our side:
    since today every package observation in HISTOR records how the run was started, next to the version it ran:
    the sandbox version, image and entry, the network (no route out), and, when we restart a server with configuration, the env variable names (never values), args and pins we supplied.

    It's in each server’s API timeline now, and in package_observations.jsonl.gz (launchJson) from the next weekly snapshot, so a join can compare lists that were launched alike.

  18. Paraphern commented on Oct 27, 2026

    @Paraphern

    +1 on aligning the quickstart with the security guide. One distinction worth making in the docs while they're being fixed, because "pinned" means two different things there: a version pin (npx -y [email protected]) fixes what gets installed - the supply-chain half your guide covers. It does not fix what the server serves: within allowed ranges, tool contracts drift silently (our npm-top audit logged 218 such changes, most inside minor bumps; this week alone, an unbounded mcp>=1.17.0 admitted mcp 2.0 and crashed servers at import, while a floating litellm range silently broke every tool-using run of a downstream agent). The second half is contract pinning - the territory of #82 and #3430. Both pins, one sentence each in the quickstart, would make the guide's advice complete.

  19. basitalisandhu commented on Oct 28, 2026

    @basitalisandhu
    Author

    Thanks @alexar76, that wording works for me as is. @Paraphern, the mongodb row is a good argument for a few more words: the same version served 9 more tools when launched without --readOnly, so the sentence could read "pin package servers to an exact version and keep the launch arguments with it". I would still keep contract pinning (#3430) out of the quickstart and link to it from the security guide instead, so the beginner page stays two sentences.

  20. Paraphern commented on Oct 28, 2026

    @Paraphern

    Agreed on the split and the two-sentence budget. "Pin package servers to an exact version and keep the launch arguments with it" is the right sentence, and the mongodb row is its cleanest one-line justification: identical package, identical version - our corpus pins it launched with --readOnly and a connection string, while @alexar76's cross-check recorded the bare launch of the same version serving 9 write tools more. Same bytes on disk, different agent-facing surface: the launch context isn't a footnote to the version pin, it's half of it.

    And yes to keeping contract pinning (#3430) behind a link from the security guide rather than in the quickstart - the beginner page should fix what installs, not what the install later becomes.

  21. alexar76 commented on Oct 28, 2026

    @alexar76

    One correction to the wording I suggested: its example versions were already out of date when I posted it. @modelcontextprotocol/server-filesystem has had 2.0.1 as latest on npm since 25 October, and mcp-server-git is at 2026.10.19 on PyPI. So the docs PR should take the versions current when it is written, and the CI check @Ben-Home suggested would keep them from going stale again.

    Written with an AI assistant.

  22. basitalisandhu commented on Oct 29, 2026

    @basitalisandhu
    Author

    Agreed, and it has already moved again: @modelcontextprotocol/server-filesystem latest is 2.1.0 (published 28 Oct) and mcp-server-git is 2026.10.28 on PyPI. So the PR should fill in versions when it is written, and the CI check is what keeps the page honest after that. @Paraphern, thanks, the mongodb row is the example I would use too.

  23. alexar76 commented on Nov 2, 2026

    @alexar76

    A correction to the remote-server line above. 4,926 of 10,929 (45%) counts registry entries, and 2,341 of them are near-identical entries of one publisher that changed together on the first day. Counted by host, 2,489 of 6,822 (36.5%) served a different tool list in those four weeks. The package numbers are unaffected.

  24. Hamza-Abili commented on Nov 10, 2026

    @Hamza-Abili

    Disclosure: I maintain an OpenCode plugin, so this is a concrete client-side data point rather than a spec proposal.

    The "recorded tool-contract hash" half is implementable today without server changes. For OpenCode I built opencode.lock.json: it records a hash per MCP tool over name, description and input schema, plus the server config hash, and the plugin denies a changed tool (and fails closed for Code Mode) until the user re-locks. It keeps description and schema hashes separate, so prose churn is distinguishable from a contract change, similar to the split @Paraphern described.

    Limitations relevant to this thread: it uses sorted-key JSON rather than RFC 8785 JCS, and it pins per project rather than per authorization scope. Both are fixable, but they are exactly the details this thread is converging on. Repo: https://github.com/Hamza-Abili/opencode-integrity (OpenCode-specific, zero runtime dependencies).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions