Skip to content

fix(plugins): load plugin skills and commands, and fix MCP discovery - #204

Open
Uking-xxx wants to merge 1 commit into
mainfrom
fix/plugin-contributions-and-mcp
Open

Uking-xxx wants to merge 1 commit into
mainfrom
fix/plugin-contributions-and-mcp

Conversation

@Uking-xxx

Copy link
Copy Markdown
Collaborator

Why

Two things a user does not expect to be broken:

  1. Install a plugin from a marketplace, restart, and the plugin has no effect.
  2. Install a plugin that declares a remote MCP server, and it never connects.

Both trace to the same gap: a plugin's contributions were discovered only partially.

Plugin skills and commands were never loaded

Nothing carried a plugin's skills / commands into a session. MCP is the one
contribution that reads the plugin root directly; the resource loader reads
~/.stepcode/agent/{skills,prompts} and <cwd>/.stepcode/..., which plugins
never write to. The two trees had no bridge, so plugins.ts parsed and validated
those fields and then nothing consumed them.

This uses the existing resources_discover channel, which already accepts skill
and prompt paths and already re-merges them on reload — so a plugin installed
during a session takes effect on the next resource reload rather than requiring a
restart. Project plugin roots are gated on project trust, matching MCP discovery:
an untrusted checkout must not inject skill instructions or prompt templates.

Four silent drops on the MCP path

  • A remote server declaring url with no command was skipped. The global
    config path and normalizeDeclaration both accept a url, so the same server
    worked from config.toml but not from a plugin.
  • mcpServers naming a sibling .mcp.json — the Claude plugin layout — was read
    as an unsupported string. readStepPluginManifest fills that field in on
    purpose for exactly this shape, and discovery then discarded it.
  • The headers spelling was not accepted, and its ${VAR:-fallback} values were
    never expanded. This is the context7 plugin's shape. Expansion is scoped to
    plugin manifests: a config.toml http_headers map stays literal, so no
    existing configuration changes meaning.
  • step mcp login read only config.toml and so could not resolve the
    <pluginId>__<serverName> name its own failure message printed. It now accepts
    the bare and published spellings (step mcp login context7 resolves
    context7__context7), refuses an ambiguous bare name rather than picking one,
    and stores credentials under the resolved name — storing the user's input
    would write a key the connection never reads.

Marketplace UI

  • Dedupe the plugin list. The built-in marketplace is materialized under every
    root and a project root is scanned alongside the global one, so each built-in
    plugin was listed once per root.
  • Rename the entries to All Plugins / Marketplaces, and drop the built-in row
    from the source list (it can be neither updated nor removed).
  • Add a search row to the plugin list, and show clone progress in place of
    handing the terminal back to the editor for the length of the clone. The clone
    is cancellable, and a successful add lands on the new marketplace's plugins.
  • Support name@marketplace for install, and name the marketplaces that do
    carry a plugin when the qualifier does not match.

Verification

  • packages/coding-agent: 3713 passed, 0 failed
  • apps/cli: 629 passed / 84 files
  • packages/tui: 953 passed, 0 failed
  • pnpm run check (biome, typecheck, all guard scripts): passes
  • New tests cover the plugin resource bridge end to end (discovery → loader →
    system-prompt listing), the trust gate, the .mcp.json indirection, header
    interpolation, and the credential-key agreement between login and connect.

./test.sh was not run to completion: it bootstraps pnpm@9.15.9 into an
isolated home whose npm userconfig is empty, so it reaches for
registry.npmjs.org, which is unreachable from this network (the environment's
npm registry is an internal mirror). The suites above are the same three the
script runs. Worth fixing separately.

Declarative plugins could contribute MCP servers, but nothing carried
their skills or commands into a session: the resource loader reads
agent/project resource directories that plugins never write to. A
marketplace plugin installed successfully and then had no effect.

Bridge the two with the existing `resources_discover` channel, which
already supports skill and prompt paths and re-merges them on reload.
Project plugin roots are gated on project trust, matching MCP discovery,
so an untrusted checkout cannot inject skill instructions.

Alongside that, MCP discovery dropped several plugin shapes silently:

- A remote server declaring `url` with no `command` was skipped, even
  though the global-config path and the normalizer both accept a url.
- `mcpServers` naming a sibling `.mcp.json` (the Claude layout) was read
  as an unsupported string, so every such plugin lost its servers.
- The `headers` spelling was not accepted, and its `${VAR:-fallback}`
  values were never expanded. Expansion now applies to plugin manifests
  only: a `config.toml` `http_headers` map stays literal.
- `step mcp login` only read `config.toml`, so it could not resolve the
  `<pluginId>__<serverName>` name its own failure message printed. It now
  accepts the bare and published spellings, and stores credentials under
  the resolved name so login and the connection agree on the key.

The marketplace UI also grew a searchable plugin list, in-place clone
progress (with a cancel path), and lands on the new marketplace's plugins
after a successful add.
@uos1231234

Copy link
Copy Markdown

Thanks for this — closing Q2 of #196 is exactly what we asked for, and routing
plugin skills / commands through resources_discover looks like the right
shape.

One thing this doesn't reach, which we'd like to understand whether it's
deliberate or an oversight: stdio MCP servers declared inline in a plugin
manifest never get a working directory.

The path today

DiscoveredServer is built without one:

// mcp.ts:365
const discovered: DiscoveredServer = {
	name,
	declaration: applyPluginHeaderAliases(normalizeDeclaration(value), value),
};
if (parsed.manifest.provision) discovered.provision = parsed.manifest.provision;

normalizeDeclaration only sets cwd when the declaration itself carries one:

// mcp.ts:480
if (typeof value.cwd === "string" && value.cwd.trim()) declaration.cwd = value.cwd.trim();

and the transport reads its working directory from exactly that field:

// mcp.ts:530
cwd: input.declaration.cwd,

So a manifest without cwd leaves the child inheriting the step process's own
process.cwd()
— wherever the user launched step from, not the plugin root.
(ctx.cwd does reach discoverStepMcpServers, but only to compute the plugin
roots; it is never handed to the spawn.)

Measured, end to end

Plugin installed at <tmp>/storage/plugins/sca-cwd-test declaring
{"command":"node","args":["server/index.mjs"]}; step launched from an unrelated
project directory; the probe server writes a file as its first statement:

manifest cwd server spawned? probe recorded
(absent) no —
"." no —
absolute path to the plugin root yes cwd=<...>/plugins/sca-cwd-test

The middle row is the one worth calling out: the obvious author-side workaround,
"cwd": ".", does not help, because normalizeDeclaration trims but never
anchors it.

All three runs exited 0, with nothing about the server on stdout, stderr, or
the --mode json event stream. A plugin whose MCP server cannot start therefore
fails completely silently — the tools are simply absent, with no hint that a
process was spawned and died.

Scope

All six MCP-form plugins in the Neriah-Ado/stepcode-plugins marketplace
declare:

"mcpServers": { "<id>": { "command": "node", "args": ["server/index.mjs"] } }

Relative, and no cwd. Each one's server/index.mjs resolves its own imports via
fileURLToPath + ./lib.mjs, so they were written expecting to start from the
plugin root. The two built-in plugins are unaffected — playwright spawns
npx @playwright/mcp@latest, steppage spawns a bare steppage-mcp — which is
probably why this hasn't surfaced upstream.

This predates the branch: main has the same shape at mcp.ts:300, :315 and
:361.

If it is worth closing

A string mcpServers already gets exactly this anchor, in the new
resolveDeclaredServers:

// mcp.ts:434
const resolved = path.resolve(pluginDir, declared);
if (!isInside(pluginDir, resolved)) return undefined;

So the inline form is the odd one out, and closing that gap is a small
self-contained change:

/**
 * Anchor a stdio server's working directory to the plugin root.
 *
 * `resolveDeclaredServers` already resolves a string `mcpServers` path against
 * the plugin directory; an inline declaration has no path to resolve, so it
 * ends up with no anchor at all. A manifest may still name an explicit `cwd` —
 * an absolute one is the author's own choice, a relative one is read against
 * the plugin root rather than the process directory, and one that escapes the
 * plugin falls back to the root instead of reaching the spawn verbatim.
 *
 * Deliberately kept out of `normalizeDeclaration`: that function is shared with
 * the global `config.toml` `mcp_servers` path (`mcp.ts:306`), where a relative
 * `cwd` means the process directory, not a plugin.
 */
function resolvePluginServerCwd(pluginDir: string, declaration: ServerDeclaration): ServerDeclaration {
	// Only a stdio server is spawned in a working directory.
	if (typeof declaration.command !== "string") return declaration;
	const declared = typeof declaration.cwd === "string" ? declaration.cwd.trim() : "";
	if (path.isAbsolute(declared)) return declaration;
	const resolved = declared ? path.resolve(pluginDir, declared) : pluginDir;
	return { ...declaration, cwd: isInside(pluginDir, resolved) ? resolved : pluginDir };
}

and at the construction site:

declaration: resolvePluginServerCwd(
	pluginDir,
	applyPluginHeaderAliases(normalizeDeclaration(value), value),
),

Two details this shape avoids: it runs after applyPluginHeaderAliases, which
returns the same object on one branch (mcp.ts:460, :513) and a new one on the
other (:472) — spreading a default cwd in front of it would let an explicit
one be clobbered; and it copies rather than assigns, so the caller's declaration
object is never mutated.

Not a merge blocker — the main thing we'd like to know is whether the current
behaviour is intended.

@uos1231234

uos1231234 commented Sep 29, 2026 •

Copy link
Copy Markdown

@uos1231234 Two corrections to my comment above, both found by auditing my own change afterwards.

1. "fails completely silent" was too strong. I verified the spawn path more closely: isMissingExecutable (mcp.ts:503) treats any ENOENT as a missing executable, and a module that cannot be resolved also produces ENOENT. So in the TUI the user is told

MCP server 'demo__demo' could not start: 'node' is not installed or not on PATH.

— while node is installed. The silent half of what I wrote is only true headless, where the extension UI is a no-op context and the notify is dropped. The TUI half is a misattributed error, which is worse than silence: the user is sent looking for a missing runtime instead of a relative path. That is the stronger version of the motivation, and it belongs in the PR rather than being left out.

2. isContained in plugins.ts:199 lets a direct parent through — and I was wrong to call that only latent.

It reads:

const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative === "" || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));

When candidate is the root's immediate parent, relative is exactly ".." — which neither starts with ".." + path.sep nor is absolute, so both negated terms are true and it is reported as contained. "../x" is correctly rejected, but plain ".." is not.

I first wrote that no caller could reach it, since normalizeRelativePath rejects .. for manifest strings and the BUILTIN_MARKETPLACE_FILES loop only passes hard-coded keys. That holds for those callers, but I had missed one: listMarketplacePlugins resolves each entry's source straight from a third-party marketplace manifest and hands the result to isContained with no other guard. A marketplace entry declaring "source": ".." therefore lists the checkout's own parent, and installMarketplacePlugin then copies that directory into the plugin root.

So it is reachable, not latent. That is a one-line fix (relative === ".."), and it changes no current behaviour at the other six call sites, so rather than leave the check for later I have taken it in the same branch as the cwd work, as its own first commit with four cases added to test/step-plugins.test.ts. My own call site depends on it as well — a manifest cwd arrives unnormalised (normalizeDeclaration only trims it), which makes it the first caller that actually exercises the escape path.

Happy to split that into a separate PR if you would rather shape it on its own — I have no strong view on whether the two belong in one branch, and the cwd work is still a reviewable commit on top of it either way.

uos1231234 added a commit to uos1231234/Step-Code that referenced this pull request Sep 29, 2026
A plugin declares its server beside its own manifest, so a relative entry in
`args` is relative to the plugin directory. Nothing told the transport that:
`normalizeDeclaration` only sets `cwd` when the manifest names one, and
`StdioClientTransport` reads its working directory from exactly that field, so
an omitted `cwd` left the child inheriting the `step` process's own working
directory.

Measured end to end against a real `step` process, with a probe server that
writes a file as its first statement: a manifest with no `cwd` never starts the
server, and neither does one naming `"."`, while an absolute path to the plugin
root does. All three runs exit 0 with nothing about the server on stdout,
stderr, or the `--mode json` event stream.

This anchors an inline stdio declaration to the plugin root, which is the
anchor stepfun-ai#204's `resolveDeclaredServers` applies to a string `mcpServers` path. A
`cwd` that escapes the plugin is refused rather than rewritten, so the server is
dropped instead of starting somewhere the manifest did not name. The global
`config.toml` `mcp_servers` path shares `normalizeDeclaration` and is left
alone, since a relative `cwd` there means the process directory.

Overlaps stepfun-ai#204 at the same construction site; if that lands first, the wrapper
belongs around its `applyPluginHeaderAliases` result, and must run after that
call, which returns the same object on one branch and a new one on the other.
uos1231234 added a commit to uos1231234/Step-Code that referenced this pull request Sep 29, 2026
A plugin declares its server beside its own manifest, so a relative entry in
`args` is relative to the plugin directory. Nothing told the transport that:
`normalizeDeclaration` only sets `cwd` when the manifest names one, and
`StdioClientTransport` reads its working directory from exactly that field, so
an omitted `cwd` left the child inheriting the `step` process''s own working
directory.

Measured end to end against a real `step` process, with a probe server that
writes a file as its first statement: a manifest with no `cwd` never starts the
server, and neither does one naming `.`, while an absolute path to the plugin
root does. All three runs exit 0 with nothing about the server on stdout,
stderr, or the `--mode json` event stream.

This anchors an inline stdio declaration to the plugin root, which is the
anchor stepfun-ai#204''s `resolveDeclaredServers` applies to a string `mcpServers` path. A
`cwd` that escapes the plugin is refused rather than rewritten, so the server is
dropped instead of starting somewhere the manifest did not name. It leans on
`isContained` from ./plugins.ts, hardened in the previous commit: a manifest
`cwd` arrives unnormalised, so this is the first caller to depend on that check
rejecting an escape rather than on an upstream filter. The global
`config.toml` `mcp_servers` path shares `normalizeDeclaration` and is left
alone, since a relative `cwd` there means the process directory.

Overlaps stepfun-ai#204 at the same construction site; if that lands first, the wrapper
belongs around its `applyPluginHeaderAliases` result, and must run after that
call, which returns the same object on one branch and a new one on the other.
uos1231234 added a commit to uos1231234/Step-Code that referenced this pull request Sep 29, 2026
A plugin declares its server beside its own manifest, so a relative entry in
`args` is relative to the plugin directory. Nothing told the transport that:
`normalizeDeclaration` only sets `cwd` when the manifest names one, and
`StdioClientTransport` reads its working directory from exactly that field, so
an omitted `cwd` left the child inheriting the `step` process's own working
directory.

Measured end to end against a real `step` process, with a probe server that
writes a file as its first statement: a manifest with no `cwd` never starts the
server, and neither does one naming `"."`, while an absolute path to the plugin
root does. All three runs exit 0 with nothing about the server on stdout,
stderr, or the `--mode json` event stream.

This anchors an inline stdio declaration to the plugin root, which is the
anchor stepfun-ai#204's `resolveDeclaredServers` applies to a string `mcpServers` path. A
`cwd` that escapes the plugin is refused rather than rewritten, so the server is
dropped instead of starting somewhere the manifest did not name. The global
`config.toml` `mcp_servers` path shares `normalizeDeclaration` and is left
alone, since a relative `cwd` there means the process directory.

The two commits are one branch on purpose. A manifest `cwd` arrives
unnormalised — `normalizeDeclaration` only trims it — so this is the first
caller that exercises `isContained`'s escape path rather than relying on an
upstream filter in front of it. Anchoring on the version hardened in the
previous commit is what makes the refusal correct; the two halves are only
sound together.

Overlaps stepfun-ai#204 at the same construction site; the call site carries a comment
with the exact form to use if it lands first.
@uos1231234

Copy link
Copy Markdown

@MelodyVAR @Uking-xxx — the change is ready but this repository restricts pull requests to collaborators, so both the UI and the API refuse it (uos1231234 does not have the correct permissions to execute CreatePullRequest). Since the work is squarely about this PR's file, I would rather leave it in this thread than open a separate issue for it.

Reviewable here (two commits, second depends on first):

main...uos1231234:fix/plugin-mcp-server-cwd

1. fix(plugins): reject a candidate in the root's direct parent

isContained reports a candidate in the root's immediate parent as contained: path.relative returns a bare "..", which neither starts with ".." + path.sep nor is absolute, so both negated terms hold. "../x" is rejected, plain ".." is not.

That is reachable — listMarketplacePlugins resolves each entry's source from a third-party marketplace manifest and hands it to isContained with no other guard, so "source": ".." lists the checkout's own parent, and installMarketplacePlugin then copies that directory into the plugin root. The other six call sites are defended today (normalizeRelativePath rejects .. for manifest strings, the built-in marketplace loop passes only hard-coded keys, the rest pair it with a second condition), so this changes no current behaviour — but it is the check standing behind seven call sites and should mean what its name says.

2. fix(step): anchor a plugin's stdio MCP server to the plugin root

The working-directory issue from my previous comment. It leans on the hardened isContained, because a manifest cwd arrives unnormalised — normalizeDeclaration only trims it — which makes this the first caller that actually exercises the escape path instead of relying on a filter upstream of it. The two halves are only sound together, which is why they share a branch.

The construction site is one line below the one #204 rewrites, and carries a comment with the exact form to use if #204 lands first, including why the wrapper has to sit outside applyPluginHeaderAliases (that function returns the same object on one branch and a new one on the other, so an anchor built in front of it gets dropped).

To apply it, if that is easier than reviewing the branch:

curl -L -o cwd.patch \
  https://github.com/stepfun-ai/Step-Code/compare/main...uos1231234:fix/plugin-mcp-server-cwd.patch
git am --3way cwd.patch

I verified that exact sequence against a clean main worktree: both commits apply cleanly, and the author/committer stay gcw_K1MKcBy1 <2424105750@qq.com>.

Validation — pnpm run check exits 0 (biome, 16 structural checks, tsgo, browser smoke). src/step/ plus test/step-plugins.test.ts: 119 passed, 2 failed, both pre-existing on a clean Windows main worktree (a POSIX separator assertion and a git-origin clone). Each commit typechecks on its own.

Happy to have any of it reworked, rebased, or split — and if you would rather open the permission so this can go through as a proper PR, that works too. Your call.

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