Extensions
An extension is a single JavaScript or TypeScript file that contributes at runtime to one or more ferridriver hosts:
- MCP server (
ferridriver mcp) — registers tools viatool(...). - BDD test runner (
ferridriver bdd) — registers Cucumber step definitions, hooks, and parameter types viaGiven/When/Then/Before/After/defineParameterType/setWorldConstructor/setDefaultTimeout. - Test runner (
ferridriver test) — contributes fixtures onto the basetestchain withdefineFixtures(...), so a spec receives them without importing anything. - Ad-hoc scripts (
ferridriver run, MCPrun_script) — same VM, same globals.
The same file can serve all four. Branch on the ferridriver.host
global to decide which contributions apply where, or narrow an entry to
some hosts in the package manifest so it never loads elsewhere.
Every host also reads defineDefaults(...), which lets a package supply
configuration defaults instead of asking every suite to copy them.
This page is the tour. The complete authoring contract — every
contribution point, the capability model, the operator ceiling, the
package manifest — is docs/extensions.md in the repository.
Mental model
Registration functions (tool, Given, Before, …) are
native Rust functions, not JS shims. Calling them at the top level
of your module pushes an entry into a Rust-owned registry. Hosts read
back the kinds they care about and invoke your handler natively — the
MCP tool path and the BDD step path use the same dispatch mechanism.
Implication: all contribution happens as a side effect of the
module's top-level code running once. There is no activate() /
onLoad() hook — ES module top-level is your load hook.
Detecting the host
ferridriver.host is a string set once per session: "mcp", "bdd",
"test", or "script". Gate your registrations so one file does not
pollute the wrong host:
Registering for the wrong host is harmless (the host ignores kinds it does not consume), but it wastes work and muddies intent.
tool
Two equivalent forms:
defineTool(...) remains as a global compatibility alias for
ferridriver.tool(...) / tool(...).
Fields
exposeAsMcpTool
false(default): the tool is callable from other extension / script code asawait tools.vendor.area.action(args), but not advertised in the MCP server'stools/list. Use for shared helpers.true: additionally promoted to a first-class MCP tool.name,description, andinputSchemabecome the tool contract. The tool call and the script binding route through the same handler.
Dotted names are projected to namespaces: tools.acme.login(args) and
acme.login(args) both call a tool named acme.login.
Handler context
The handler receives one object:
Return any JSON-serialisable value; it becomes the tool result.
When the manifest declares inputSchema, the caller's args are
validated against it (full JSON Schema, via the jsonschema crate)
before the handler runs; a non-conforming call is rejected as a
tool error and the handler is never entered.
Contributing fixtures
defineFixtures adds fixtures to the base test chain, so a suite that
never imports the package still receives them through the test it
already imports:
Entries and override rules are test.extend's: packages compose in load
order, a later same-name entry shadows the earlier one, and that entry's
own same-name dependency resolves to the registration it shadows.
The base chain seals once every extension has installed — each
test.extend() copies it from then on, so a later contribution would
reach some suites and not others. A defineFixtures from a spec bundle,
a step file or a script throws and points at test.extend().
An operator can close the door entirely with [extensions.policy] fixtures = false, which refuses the contribution and fails the run
naming the key. It covers defineFixtures only: a package's own
test.extend / mergeTests chains and its expect.extend matchers are
never clamped.
Contributing config defaults
defineDefaults supplies defaults for the run's configuration, so a
suite that adopts a package does not have to copy its settings into
every ferridriver.toml:
It is the lowest layer: every config file, every FERRIDRIVER_*
override and every CLI flag still wins, and two packages setting the
same key compose in load order. ferridriver config names the package a
value came from just as it names a file.
The set of extensions is itself configuration, so the run resolves the
layer stack twice: once to learn which packages to load, then again with
what they contributed underneath. That is why a contribution may not set
extensions, bundler, scripting or [test].moduleAliases — each
decides how the contributing package was found, compiled or trusted, and
each is refused by name. A key the schema does not have is refused too,
naming the key: a typo in a dependency's defaults is one nobody would
otherwise see.
Contributions are read before the first session exists; calling
defineDefaults from a spec, a step file or a script throws. An
operator closes the door with [extensions.policy] configDefaults = false.
Discovery and configuration
Extensions are configured in ferridriver.toml:
ferridriver bdd bundles discovered step files and the configured
extensions into one module, so an extension's Given / When /
Then are available to tests exactly like a step file's.
A package that declares a ferridriver field in its package.json
names its own entries, which is what a package with several tool modules
plus a shared lib/ needs — Node's single-entry fields can only name
one:
An item is a path, or an object that narrows the entry to some hosts
(mcp, bdd, test, script) or gives it its own requires. An
entry's requires are checked only where the entry runs, so an MCP-only
dependency does not hold the package back under ferridriver test.
Without that field, standard ESM package metadata applies:
exportsis preferred, including conditionalimport/default.moduleis accepted.mainis accepted only when it points at an ESM source entry.index.mjs,index.mts,index.ts, andindex.jsare used as fallback entries;.jsrequires"type": "module".
CommonJS entries are intentionally rejected.
Both discovery paths (MCP loader and BDD runner) share the same package
resolver, accepted-extension set, and recursive walk. A package, package
subpath, .tsx, or .cts extension is visible identically to both
hosts.
Runtime guarantees
inputSchemais enforced. Calls whose arguments do not match the declared schema are rejected before your handler runs. A schema that is itself invalid JSON Schema is reported, not silently ignored.- Tool names are unique and non-empty. A duplicate or blank
namefails that extension at load time. A name that collides with a built-in or another loaded tool is not exposed. Namespace your names (vendor.area.action). - Tool failures are reported as errors. When your handler throws,
the caller gets an error result (not a "success" containing an
error string), with the message first and full detail after. (Plain
run_scriptis different: it always succeeds and you inspect itsstatusfield.) timeoutMsis honoured for every caller — whether the tool is invoked as a promoted MCP tool or by another extension. Without it, only the session-wide script timeout applies.- Discovery is recursive and uniform. Configured ESM packages,
package subpaths, files, and directories resolve the same way for
the MCP server and the test runner. Directories are scanned
recursively;
.js .cjs .mjs .jsx .ts .cts .mts .tsxsource files are all accepted. - You can inspect what loaded. The built-in
ferridriver_extensionsMCP tool lists every loaded extension file, its tools, descriptions, whether each is exposed, its timeout, and its declared capabilities.
What is intentionally not provided
activate()/onLoad()hook. Module top-level is the load hook; ES module evaluation runs your registrations.- Plugin dependency / ordering. The loader sorts files deterministically by path; cross-file load ordering is not configurable.
- Cross-plugin shared state channel. Share helpers via
importstatements (rolldown will resolve and bundle them); there is no global registry. - Middleware / hook pipeline (Rollup-style ordered hooks). Not shipped — no consumer today justifies the abstraction. The capability boundary is the natural insertion point if one ever does.
See Capabilities for allow.commands and
allow.net. See BDD JS / TS API for Given
/ When / Then reference.