diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f47b34 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: prepare-release + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - name: Require a chosen reuse license + run: node -e "const p=require('./package.json'); if(!p.license || p.license==='UNLICENSED') throw new Error('Owner must choose a reuse license before release');" + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm run check + - run: npm run release:prepare + - name: Create a draft experimental GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(node -p "require('./package.json').version") + gh release create "v$version" output/release/*.tgz output/release/SHA256SUMS --target "$GITHUB_SHA" --title "Hardwire $version — local MCP toolkit" --notes-file "docs/releases/v$version.md" --prerelease --draft diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 262ae71..fadf7a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,5 +20,5 @@ jobs: with: node-version-file: ".nvmrc" package-manager-cache: false - # No install step: the package has no dependencies by design. + - run: npm ci --ignore-scripts --no-audit --no-fund - run: npm run check diff --git a/CHANGELOG.md b/CHANGELOG.md index af612d2..90bf6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.5.0 — 2026-11-12 + +Standalone agent access without an owned application. + +- Adds `hardwire-mcp`: five fixed offline tools for panel inspection/compilation, + HTML inference, parameter validation and panel-bound trace verification. +- Uses official MCP server SDK 2.4.0, with a separate official client dependency + for transport tests. Core format helpers retain their built-in-only imports. +- Adds exact-path client configuration, first-task examples and troubleshooting. +- Tests actual discovery/calls, invalid inputs and installed-command startup. + The clean-install test caught and fixed symlink-based executable detection. +- Replaces the owned-app prerequisite with a standalone toolkit distribution path. + +Existing panel and trace formats are unchanged. Install dependencies with +`npm ci --ignore-scripts` before running the full checks. The toolkit does not +execute submitted ports or fetch websites. License selection remains pending. + ## 0.4.1 — 2026-11-12 Final use-readiness audit and runnable consumer pattern. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8699426..647b6e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,16 +4,19 @@ The repository's license has not been chosen. These instructions describe the owner's development workflow; they do not grant outside reuse rights or invite code contributions under an unspecified license. -Use Node 24.16+ (`nvm use`). There are no dependencies or generated build files. +Use Node 24.16+ (`nvm use`). The MCP transport uses the pinned official SDK. +The core format modules still use only Node built-ins; no build step is needed. ```sh +npm ci --ignore-scripts npm run check ``` This runs unit/CLI tests, real loopback HTTP integration tests, panel validation -and the offline API walkthrough, runnable consumer and package smoke check. The -package check builds a tarball, installs it offline in a temporary project, then -checks its ESM exports, CLI binary and included consumer example. CI runs the +and the offline API walkthrough, runnable consumer, MCP transport and package checks. The +package check builds a tarball, installs it with a fresh cache in a temporary project, then +checks its ESM exports, CLI binaries, consumer and actual MCP calls through the +installed symlink. The official SDK client is a development dependency. CI runs the same command on the exact `.nvmrc` version. All checks must pass before merging. The standalone `npm run corpus` command intentionally exits 1 on the current baseline; its expected scores and diff --git a/README.md b/README.md index 4eef912..66facd6 100644 --- a/README.md +++ b/README.md @@ -9,38 +9,49 @@ Hardwire describes an application's operations in a small JSON file called a `read` or `payment`, and a declared confirmation requirement. An agent integration can inspect that contract instead of guessing which UI to click. -**Status: 0.4.1 experimental foundation.** This Node.js library validates panels -and arguments, compiles MCP tool descriptions, proposes panels from static HTML, -and creates verifiable trace digests. It does **not** execute operations, provide -an MCP server, log anyone in, or enforce human approval. No React dependency. +**Status: 0.5.0 experimental MCP toolkit and Node.js library.** Connect an agent +to five local tools for HTML inference, panel inspection, tool-description +compilation, argument validation and trace verification. You do not need an app, +a website, an API key or hosting. The toolkit processes supplied data; it does +not browse websites, log in or execute the operations described in a panel. -## Start in two minutes +License selection is pending. The setup below is for the owner or users with +separate permission; the public repository currently grants no reuse license. -Use **Node 24.16 or newer** (`nvm use` if you use nvm). No dependencies, API keys, -accounts, installation step, or paid services are needed. +## Connect in five minutes + +Use **Node 24.16 or newer** (`nvm use` if you use nvm). ```sh git clone https://github.com/Automatron-workflows/hardwire.git cd hardwire -node --version -npm run walkthrough -npm run consumer -npm test +npm ci --ignore-scripts +npm run test:mcp +npm run --silent mcp:config ``` -The offline walkthrough exercises the public API, verifies a trace, demonstrates -password redaction, and scores a sample inference. `npm test` also starts a -short-lived server on the loopback interface. In a sandbox that forbids listening -sockets, run `npm run test:unit`; the integration suite must still pass in CI. - -`npm run consumer` goes one step further: it maps `search_catalogue` to a known -application function, returns a matching demo product, checks the result and -records a successful trace. It runs offline on fake data. This is a runnable -integration pattern, not an MCP server or an authenticated deployment. +The smoke check launches a real server, discovers all five tools and calls them +with the official MCP client. The last command prints a client configuration +with exact paths for this machine. Merge its `hardwire` entry into your MCP +client's configuration. [Full setup, a first prompt and troubleshooting](docs/MCP.md). -**Next:** [step-by-step experiments](docs/GETTING-STARTED.md), -[API reference](docs/API.md), or [adding a panel to your app](docs/INTEGRATION.md). -For adoption and packaging, see [distribution and first deployment](docs/DISTRIBUTION.md). +| Tool | What it does | +| --- | --- | +| `hardwire_inspect_panel` | Validates a panel and summarizes declared effects. | +| `hardwire_compile_panel` | Produces MCP descriptions as data, without registering them. | +| `hardwire_infer_html` | Turns supplied static HTML into a draft that needs review. | +| `hardwire_validate_params` | Checks arguments against a port without executing it. | +| `hardwire_verify_trace` | Checks trace consistency and binding to a supplied panel. | + +To explore the library without an agent, run `npm run walkthrough` and +`npm run consumer`. The consumer calls one fixed function on fake catalogue data. +Run `npm run check` for all tests, including a clean package installation and +MCP calls through the installed command. HTTP integration tests need loopback +sockets; restricted sandboxes can run `npm run test:unit` as a partial check. + +**Next:** [MCP setup](docs/MCP.md), [library experiments](docs/GETTING-STARTED.md), +[API reference](docs/API.md), [application integration](docs/INTEGRATION.md), +or [release and distribution](docs/DISTRIBUTION.md). ## What a panel looks like @@ -70,7 +81,7 @@ consumer needs an explicit implementation for each port it will call. | --- | --- | | Panel | The versioned description of an application's operations. | | Port | One operation, its argument schema and declared policy. | -| Consumer / plug | Your integration that maps ports to real handlers. Not included. | +| Consumer / plug | An integration mapping application ports to real handlers. See the demo consumer. | | Trace | A record of a caller's assertion, bound to a panel digest. Not proof of execution. | ## Try the commands diff --git a/docs/API.md b/docs/API.md index ef42ed0..78e2d37 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # Public API -Node.js ESM, zero dependencies. Import from `hardwire` after a permitted local +Node.js ESM. Core helpers use only Node built-ins; the MCP server uses the official SDK. Import from `hardwire` after a permitted local package install, or from `./src/index.mjs` inside the checkout. Subpath exports are listed in `package.json`. Functions are synchronous unless marked async. diff --git a/docs/AUDIT-2026-11-12.md b/docs/AUDIT-2026-11-12.md index 0a72dbd..412d76c 100644 --- a/docs/AUDIT-2026-11-12.md +++ b/docs/AUDIT-2026-11-12.md @@ -55,3 +55,31 @@ also uses current SHA-pinned actions and the exact supported Node version. The remaining adoption blockers are product boundaries, not failing tests: license selection, an owned npm identity, and a real client adapter with scoped application access. See DISTRIBUTION.md. None is claimed to be released already. + + +## Follow-up: standalone distribution, 0.5.0 + +The first useful client integration no longer depends on owning an application. +Five fixed offline tools are callable through the official SDK's stdio server. +An SDK client exercises discovery, all handlers, rejection cases and shutdown. +Compiled panel ports are not registered as executable tools. + +A clean package install found that entry-point detection compared a real module +path with npm's symlink path, silently doing nothing when launched through the +installed command. Resolve the entry-point symlink before comparison and test +both `--version` and real MCP calls through the installed command. + +Documentation now separates the standalone toolkit from future partner-owned +application execution. Public reuse still awaits an explicit license decision. + +Connected CI passed 107 unit/CLI tests and four HTTP integration tests, plus the +official SDK client smoke check and a clean installed-command MCP check. The +resolved dependency graph is committed with npm integrity hashes; CI uses +`npm ci --ignore-scripts`. Local unit checks also pass. No external deployment, +registry listing or model-host UI compatibility result is implied by these tests. + +Switching CI to `npm ci` exposed an obsolete test assumption: npm caches the +locked tarballs without necessarily caching registry metadata. A new packed +consumer install therefore cannot assume `--offline` works. The package test now +uses a fresh temporary cache and the real online installation path. Runtime +toolkit calls remain offline. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 8466d30..25fb62c 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -1,97 +1,93 @@ -# Distribution and first deployment - -## What is ready now - -0.4.1 is usable by the owner as a Node.js contract, validation, inference and -trace library. `npm run consumer` proves one fixed read operation can execute -through an application-owned handler. `npm run check` also installs the actual -packed artifact in a clean temporary project and exercises its exports and CLI. - -It is not yet a product that can be connected to an agent client and operate -arbitrary websites. There is no transport, identity service or general executor. -Public GitHub visibility does not provide a reuse license: `UNLICENSED` and -`private: true` are deliberate release blockers, not installation bugs. - -## First working deployment - -Use one application we control and an existing backend operation. Do not start -by tagging every UI element, handling third-party credentials or building a registry. - -1. Publish a reviewed panel for three useful read operations: search, fetch one - record, and read status. Keep auth tokens and private data out of the panel. -2. Copy the consumer pattern into the app's trusted backend. Map each stable port - ID to an existing service function; scope every read to the authenticated user. - Bind the accepted panel revision to deployment configuration. -3. Add a small MCP adapter using a pinned official SDK. Implement initialization, - `tools/list` with Hardwire descriptions, and `tools/call` with known handlers. - Use stdio for the first local client; introduce remote hosting only when needed. - Remote authentication and authorization are additional deployment work. -4. Connect one supported MCP client and complete actual user tasks. Record the - client, adapter/SDK version, app revision, failures and observed results. -5. Add exactly one reversible write after the read path works. Require explicit - authorization and approval bound to the user, port, arguments and panel digest. - Implement idempotency, a checked result and read-back in the application. -6. Test denied access, cross-user access, a changed panel, invalid input, timeout, - repeated calls and refused approval. Treat untrusted results as data. - -Acceptance target for this owned-app pilot: at least 20 recorded read tasks with -checked results, all denial cases refused, and no duplicate effect under a retried -write. These are proposed targets, not measurements already achieved. This does -not replace the separate real-page inference evaluation in ROADMAP.md. - -Login should use the app's established authentication flow and scoped session. -A declared login port is not a reason to collect passwords in prompts or traces. - -## Release the library - -1. **Choose the license.** The owner must choose the permissions granted to outside - users. Add the matching LICENSE file and package metadata. This document does - not select a license or grant those rights. -2. **Choose an owned npm scope.** Candidate: `@automatron-workflows/hardwire`. - Availability and ownership are unverified; the GitHub organization does not - automatically own that npm scope. Do not advertise `npm install hardwire`. -3. Update the package name and install examples, remove `private: true` only for - the release, run `npm run check`, and inspect `npm pack --dry-run` for unintended - files. Freeze canonicalization test vectors before interoperability claims. -4. Publish a versioned experimental release and tag it on GitHub. Verify installing - that exact version from npm in a clean project; do not rely only on the checkout. -5. For ongoing releases, configure npm trusted publishing from the specific - GitHub repository/workflow, with provenance and release checks. Account/scope - ownership and first-package setup must be resolved before this can work. - Keep release credentials out of the repository. - -No publishing workflow is enabled by this audit. Publishing still requires the -license, package identity, account access and an explicit release action. - -## Distribution to developers and agent users - -| Audience | Deliverable | Evidence needed before promoting it | -| --- | --- | --- | -| Application developers | Licensed npm library, GitHub README and one small integration example | Fresh install and the owned-app pilot work end to end. | -| Agent users | Runnable MCP adapter with client setup instructions | Tested `tools/list` and `tools/call` in one real client. | -| Additional integrators | A concise case study and a reproducible failure report | Three independent teams can integrate without bespoke patches. | - -The MCP Registry is a discovery layer for actual MCP servers; it does not host -the package or convert a JSON panel into a server. List the adapter only after -it exists and its artifact or remote endpoint is available. Do not list the -current core library as an operational MCP server. - -Start with direct onboarding of three teams that own both an app and its agent -integration. Measure time to first successful task, integration code required, -task success, denied unauthorized calls and repeated use after one week. Publish -the measured before/after workflow and a short recording. Broader developer -announcements should link to that evidence and an installable artifact. Stars, -registry presence and a synthetic score are not adoption. - -The initial promise should be narrow: **expose a few real application operations -as validated agent tools without teaching the agent to navigate the UI**. Avoid -claims of universal compatibility, automatic login or unmeasured speed gains. - -## Sources checked on 2026-11-12 - -- [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/): OIDC-based - release authentication and provider configuration. -- [MCP Registry publishing quickstart](https://modelcontextprotocol.io/registry/quickstart): - metadata, underlying artifacts, identity verification and server publication. -- [MCP tools specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools): - tool discovery and calls. Core Hardwire implements descriptions only. +# Distribution without owning an app + +## The first product + +Hardwire 0.5.0 is a local MCP toolkit for inspecting agent interfaces. Its five +fixed tools process supplied HTML, panels, arguments and traces. A user can +connect it to a local MCP host without building an app or running a service. +See [MCP setup](MCP.md) for a first task and exact-path client configuration. + +Application execution remains a later partner integration. The toolkit does not +log in, operate arbitrary websites or make inferred descriptions executable. +Keep this distinction in every listing. + +## Release status + +| Item | State | +| --- | --- | +| GitHub source | Public under Automatron-workflows/hardwire | +| MCP transport | Implemented with official SDK 2.4.0 | +| Clean installation | CI installs the packed artifact and exercises its MCP command | +| Public reuse license | Awaiting owner decision; UNLICENSED remains in effect | +| npm package | Not published; unscoped name is not reserved | +| MCP Registry listing | Not submitted | +| External users or measured demand | Not established | + +## First distribution: GitHub + +GitHub is already available and needs no new npm account or paid hosting. + +1. Resolve the license. [The release proposal](RELEASE-DECISION.md) recommends + MIT for low-friction adoption and explains the rights it grants. +2. Run the checks from the committed lockfile. Create the experimental `v0.5.0` + tag and a GitHub prerelease with the tested package and SHA-256 checksum. + Use [the prepared release notes](releases/v0.5.0.md) after licensing. +3. Verify the exact downloaded asset in a fresh directory with an MCP client. + Link the source, license, setup and limitations. +4. Offer one concrete experiment: infer a panel from a small saved HTML page, + inspect it, and identify which declarations need review. + +This document does not publish a release. Do not advertise a tag, asset URL, +package name or registry listing before it exists and has been verified. + +`npm run release:prepare` builds a local package and checksum for owner review. +After licensing, run the GitHub Actions `prepare-release` workflow on main. It +checks the license, runs the full suite and creates a **draft** prerelease with +assets. Review that concrete draft before publication. This does not publish to +npm or require an npm account. The workflow refuses the current UNLICENSED state. + +## Then npm and discovery + +Verify an npm account/scope controlled by the owner. `@automatron-workflows/hardwire` +is a candidate, not a verified identity. Rename the package and regenerate its +lockfile, resolve license metadata, and remove `private: true` only for the +intentional package release. Never direct users to `npm install hardwire`, which +can resolve to unrelated software. + +Publish an experimental exact version and verify installing it from the registry. +Then configure trusted publishing for the specific repository and workflow. +Keep npm tokens out of source, examples and issue reports. + +The MCP Registry lists server metadata, not package artifacts. The npm route +needs a published package, matching `mcpName`, `server.json` and verified namespace +ownership. A GitHub source link alone is insufficient. MCPB bundles are a possible +later alternative; do not claim a bundle exists until its installation is tested. +No hosted endpoint is needed for a local package. + +## Reach the first users + +Start with developers already using MCP and browser automation. They can try the +toolkit without controlling a website. After licensing, publish the release and +a reproducible demo, then seek three independent evaluations. + +Measure whether people connect without help, complete the first task, understand +limitations and return for a second project. Record client/version, setup time, +failed commands and useful findings. Use voluntary feedback; there is no built-in +tracking. Downloads, directory presence and stars do not establish demand. + +Obtain explicit outreach instructions before sending promotional messages. +Potential channels are the MCP Registry, relevant developer directories and a +technical demo post. Avoid universal-compatibility, automatic-login, proven-safety +or unmeasured-speed claims. + +## Application execution after a partner exists + +With a consenting app team, start with three explicit read handlers behind its +existing authorization. Add one reversible write with argument-bound approval, +idempotency and read-back verification. The partner owns handlers and credentials. +Inspecting a contract does not grant permission to execute it. + +Sources checked 2026-11-12: +[MCP Registry](https://modelcontextprotocol.io/registry/quickstart), +[npm trusted publishing](https://docs.npmjs.com/trusted-publishers/), +[MIT license](https://choosealicense.com/licenses/mit/). diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 43c977f..b9c1507 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -1,14 +1,17 @@ # Experiment with Hardwire Run every command below from the repository root with Node 24.16+. +For an agent-connected experience, start with [the standalone MCP toolkit](MCP.md). +Install dependencies with `npm ci --ignore-scripts` for MCP checks. `npm run walkthrough` is the fastest complete offline tour. It makes no network requests and needs no model or credentials. To see a tool actually call an application function, run `npm run consumer`. It calls `search_catalogue` with `{ "query": "lamp" }`, returns the fake `Desk lamp` product and records a checked success. Read `examples/consumer.mjs` -to replace the fixed handler with your own authorized service. A model client -still needs a transport adapter; merely publishing a panel cannot make calls. +to replace the fixed handler with your own authorized service. The standalone MCP toolkit inspects supplied data; calling this catalogue function +from a model still needs a separate application adapter. Publishing a panel alone +cannot make its operations executable. ## 1. Inspect an explicit contract diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index c1a4ce0..e376dde 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -1,5 +1,8 @@ # Add a panel to an application +No application to integrate yet? Use [the standalone MCP toolkit](MCP.md). +This guide covers the later, separate step of operating an application. + Hardwire's useful boundary is the application's real operations. Start with one read operation on a site you control. Do not tag every button or infer that an inferred draft is permission to call a third party. @@ -62,7 +65,7 @@ Checked against primary sources on 2026-11-12: | System | Relationship | | --- | --- | -| [MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) | Hardwire produces the tool-list result shape. It implements no transport, initialization or `tools/call` handler. | +| [MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) | The core compiles tool descriptions. The standalone toolkit implements stdio initialization and calls through the official SDK for five fixed inspection tools; it never executes submitted panel ports. | | [MCP annotation guidance](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/) | Annotations are untrusted hints unless backed by a trusted publisher and enforcement. They do not prove safety. | | [WebMCP draft](https://webmachinelearning.github.io/webmcp/) | Browser-side JavaScript tool registration. The 2026-11-11 draft uses `document.modelContext`; older proposals used `navigator.modelContext`. No adapter is implemented here. | | OpenAPI | Existing HTTP interface descriptions. Hardwire does not convert OpenAPI documents; the probe only checks a conventional discovery path. | diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..99da36f --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,104 @@ +# Connect Hardwire to an agent + +The local toolkit works even if you do not own an app. An agent can inspect a +panel, infer a draft from HTML, compile tool descriptions, check arguments and +verify traces. It never calls an endpoint from a panel. License selection is +pending; setup is currently for the owner or users with separate permission. + +## Install and verify + +Use Node 24.16+ and npm. From a checkout: + +```sh +npm ci --ignore-scripts +npm run test:mcp +npm run --silent mcp:config +``` + +Installation downloads the pinned dependencies. The five tools then run offline. +No service account, API key, app or hosting is required. The smoke check launches +a real server process and uses the official SDK client to list and call all five +tools, test refusals and close the connection. This is a protocol check, not a +claim that every host UI has been tested. + +## Configure a client + +`mcp:config` prints JSON with the exact Node and server paths on your machine: + +```json +{ + "mcpServers": { + "hardwire": { + "command": "/absolute/path/to/node", + "args": ["/absolute/path/to/hardwire/src/mcp-server.mjs"] + } + } +} +``` + +Merge the `hardwire` entry into a client using the `mcpServers` convention. Keep +existing entries intact, reload the client and check for five `hardwire_*` tools. +An absolute Node path avoids desktop applications missing nvm's shell PATH. + +In VS Code, use **MCP: Add Server**, choose command/stdio and enter the same +command and script argument. Its JSON uses `servers` rather than `mcpServers`, +with `type: "stdio"` on the entry. Review the command before trusting it. A client +that supports only remote URLs cannot connect to this local stdio server. + +Launch `node src/mcp-server.mjs` directly from clients. `npm run mcp` without +`--silent` can print npm's banner to the protocol stream. A terminal waiting +quietly is expected: the server waits for an MCP client, not a browser. + +## First task + +Ask your connected agent: + +> Use Hardwire to infer a panel from this HTML with origin https://example.com. +> Inspect the draft, then compile its tool descriptions. Explain what needs +> human review. Do not visit the website or execute an inferred operation. +> +> `
` + +Expected: one candidate read port with required string argument `q`, a draft and +a compiled description. The effect is inferred from markup and needs review +against actual behavior. The origin is a label; no request is sent to it. + +Next validate arguments `{"q":42}` against the draft's port ID: expect +`isError: true`. Change GET to POST and infer again: expect a declared write with +user confirmation. This remains data; the toolkit cannot invoke that operation. + +## Tool arguments + +| Tool | Arguments | +| --- | --- | +| `hardwire_inspect_panel` | `panel_json`: serialized panel | +| `hardwire_compile_panel` | `panel_json`: serialized panel | +| `hardwire_infer_html` | `origin`: bare HTTP(S) origin; `html`: static HTML | +| `hardwire_validate_params` | `panel_json`, `port_id`, `params_json`: serialized arguments | +| `hardwire_verify_trace` | `panel_json`, `trace_json`: serialized trace | + +JSON is supplied as text so the toolkit can validate its own contract and return +bounded errors. Unknown outer arguments are rejected. Validation returns no +argument values. Trace verification does not establish authorship, execution or +human approval. Use sanitized HTML and fake arguments; your host/model provider +determines where chat content goes. Hardwire itself has no telemetry. + +## Troubleshooting + +| Symptom | Action | +| --- | --- | +| `ERR_MODULE_NOT_FOUND` | Run `npm ci --ignore-scripts` in the checkout. | +| Node cannot be found | Regenerate `mcp:config` with the intended Node version. | +| No tools appear | Run `npm run test:mcp`, check host logs and reload configuration. | +| Terminal waits silently | Expected for stdio; configure a client to launch it. | +| Unknown tool such as `search_catalogue` | Only the five toolkit tools are callable. Compiled descriptions are data. | +| `hardwire_input_invalid` | Supply valid JSON text within 256 KiB, with no extra arguments. | +| `hardwire_infer_failed` | Check origin and HTML size (512 KiB UTF-8); dynamic rendering is unsupported. | +| Trace returns `valid: false` | Shape, digest, arguments or panel binding failed validation. | + +The checks use official server/client SDK 2.4.0. The SDK handles initialization +and protocol negotiation. There is no hosted HTTP endpoint or browser dependency. + +Sources checked 2026-11-12: [SDK stdio](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio), +[SDK tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools), +[VS Code setup](https://code.visualstudio.com/docs/agent-customization/mcp-servers). diff --git a/docs/RELEASE-DECISION.md b/docs/RELEASE-DECISION.md new file mode 100644 index 0000000..ddbadc0 --- /dev/null +++ b/docs/RELEASE-DECISION.md @@ -0,0 +1,23 @@ +# Public release decision + +The technical candidate is 0.5.0: a local MCP toolkit, existing library, client +setup and tests. Public source is on GitHub. LICENSE currently withholds reuse +and redistribution rights. + +**Proposal: standard MIT license, retaining Copyright (c) 2026 Automatron.** + +MIT permits people and businesses to use, modify, redistribute and sell copies +provided the copyright and license notice remain. They may keep modifications +private. It does not preserve exclusivity over the distributed code or require +users to pay. This supports adoption of a developer toolkit. Revenue would need +to come from separate services, hosting, integrations or support; none is promised +by this release. + +The proposed terms are the [standard MIT text](https://choosealicense.com/licenses/mit/). +This document is a proposal only. It does not apply that license or grant rights. +The owner must choose before public release and an external reuse invitation. + +After selection, replace LICENSE, set package metadata to MIT, update the license +notices in README/CONTRIBUTING/MCP/setup/distribution/roadmap, rerun CI and publish +the GitHub prerelease. npm scope and authentication can be resolved later; they +need not delay a licensed GitHub release. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9bbacdf..bd437b0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Foundation and next gates -## Foundation: implemented in 0.4.1 +## Foundation: implemented in 0.5.0 - Strict panel and schema validation, argument checks and normalized digests. - MCP tool descriptions with the same closed-object semantics as validation. @@ -10,6 +10,8 @@ - Trace creation, redaction, verification and exact panel binding. - Offline walkthrough, local HTTP demo, CLI/API guides and automated checks. - An application-owned read-handler example and automated packed-install checks. +- A standalone offline MCP toolkit with five fixed tools, official SDK transport, + actual client calls, installed-command tests and generated client configuration. The library is ready for controlled experiments by the owner. It is not a production executor or an open-source release. License selection is still open. @@ -44,7 +46,7 @@ The synthetic baseline must stay separate from a real-site evaluation. ## Gate 2: controlled traversal — open -On one application we own, complete login, read, one reversible write, and +With a consenting application partner, complete login, read, one reversible write, and read-back without UI navigation. Every action must have a known handler, authorization, explicit approval policy, idempotency behavior, a checked result and a trace bound to the reviewed panel. Inject failures and retry scenarios. @@ -59,7 +61,8 @@ allowlist of local handlers for the first experiment. ## Gate 3: demand — open -Find one external team with a real application that wants to publish a panel or +First recruit users for the standalone toolkit; no app ownership is required. +Separately, find one consenting application team that wants to publish a panel or consume one. Measure the integration work and task reliability. Stars and synthetic examples do not satisfy this gate. Resolve the license before inviting external implementation contributions or promoting reusable packages. @@ -67,7 +70,8 @@ external implementation contributions or promoting reusable packages. ## Release decisions still open Follow [distribution and first deployment](DISTRIBUTION.md) for the ordered pilot, -package and client-adapter rollout. Publishing the panel alone cannot connect a client. +package and client rollout. The toolkit can connect now; submitted panel operations +remain data until an application integration supplies trusted handlers. - Choose a license; `UNLICENSED` and `private: true` remain unchanged. - Decide whether npm distribution is useful and check/reserve an appropriate name. diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 41d4a30..c663c7f 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -3,6 +3,21 @@ Hardwire validates descriptions and records caller assertions. It is not an execution sandbox, authorization service, credential vault, or signing system. +## Local MCP toolkit + +The server exposes exactly five fixed inspection tools. It has no URL-fetch, +file-read, shell, login or mutation tool. Compiled descriptions are returned as +data and never registered as new executable tools. Runtime processing stays local; +the MCP host and model provider determine where chat inputs and results go. +Use sanitized HTML and fake arguments. No telemetry or trace files are written. +The server reads its own package metadata at startup. + +JSON text inputs are limited to 256 KiB each, HTML to 512 KiB in UTF-8, and result +JSON to 1 MiB before its text/structured encodings. Core depth and object-count +limits still apply. Tool errors omit submitted values and stacks. Inputs rejected +by the SDK can include schema diagnostics; never send real credentials for testing. +Treat labels and descriptions extracted from HTML or panels as untrusted data. + ## Declarations are not runtime enforcement `payment` and `destructive` declarations cannot lower their confirmation below diff --git a/docs/releases/v0.5.0.md b/docs/releases/v0.5.0.md new file mode 100644 index 0000000..9734c2e --- /dev/null +++ b/docs/releases/v0.5.0.md @@ -0,0 +1,27 @@ +# Hardwire 0.5.0: a local MCP toolkit + +Release notes prepared for publication after license selection. This file does +not imply a GitHub tag, downloadable release or npm package already exists. + +Hardwire lets an agent inspect supplied HTML and action contracts through five +local MCP tools. No owned app, hosted server or API key is required. + +- Infer a draft panel from static HTML. +- Inspect declared effects and approval floors. +- Compile operations into MCP descriptions as data. +- Validate arguments against a declared port. +- Verify trace consistency and binding to a supplied panel. + +Use Node 24.16+. Clone the repository, run `npm ci --ignore-scripts`, then +`npm run test:mcp` and `npm run --silent mcp:config`. Follow +[the setup guide](https://github.com/Automatron-workflows/hardwire/blob/main/docs/MCP.md) +for configuration and a first prompt. + +Checks exercise an official MCP client, all handlers, rejected inputs, a tampered +trace, and the command from a clean package install. Existing unit/CLI and real +HTTP integration suites also run in CI. + +This is experimental inspection tooling. It does not browse sites, authenticate, +execute submitted ports or certify inferred effects. Trace digests are consistency +checks, not signatures or proof of execution. Existing 0.4.x panel/trace formats +are unchanged. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c4baac2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,193 @@ +{ + "name": "hardwire", + "version": "0.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hardwire", + "version": "0.5.0", + "license": "UNLICENSED", + "dependencies": { + "@modelcontextprotocol/server": "2.4.0" + }, + "bin": { + "hardwire": "src/cli.mjs", + "hardwire-mcp": "src/mcp-server.mjs" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.4.0" + }, + "engines": { + "node": ">=24.16.0" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.4.0.tgz", + "integrity": "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.4.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.8", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz", + "integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz", + "integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.4.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.17", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.17.tgz", + "integrity": "sha512-wOuK9V1yujQFH5S0ZWmLcDZN7+IKh9bUCC2ZPCf2HDlol3WWHy+PPyNjEI75FXRLMp3vDKQcupP59xhVU95hcg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index a1ade8a..73e73ff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hardwire", - "version": "0.4.1", - "description": "Typed action panels, static inference, MCP tool descriptions and verifiable trace digests. Experimental; no executor.", + "version": "0.5.0", + "description": "Local MCP toolkit for inspecting HTML, validating action panels and checking trace digests. Experimental; no website executor.", "type": "module", "private": true, "license": "UNLICENSED", @@ -17,7 +17,8 @@ "node": ">=24.16.0" }, "bin": { - "hardwire": "./src/cli.mjs" + "hardwire": "./src/cli.mjs", + "hardwire-mcp": "./src/mcp-server.mjs" }, "exports": { ".": "./src/index.mjs", @@ -31,6 +32,7 @@ }, "files": [ "src", + "scripts/client-config.mjs", "examples", "docs", "README.md", @@ -48,10 +50,20 @@ "demo": "node examples/demo/server.mjs", "walkthrough": "node examples/walkthrough.mjs", "consumer": "node examples/consumer.mjs", + "mcp": "node src/mcp-server.mjs", + "mcp:config": "node scripts/client-config.mjs", + "test:mcp": "node scripts/check-mcp.mjs", + "release:prepare": "node scripts/prepare-release.mjs", "test:package": "node scripts/check-package.mjs", "test:unit": "node --test test/*.test.mjs", "test:integration": "node --test test/integration/*.test.mjs", - "check": "npm test && npm run validate && npm run walkthrough && npm run consumer && npm run test:package" + "check": "npm test && npm run validate && npm run walkthrough && npm run consumer && npm run test:mcp && npm run test:package" + }, + "dependencies": { + "@modelcontextprotocol/server": "2.4.0" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.4.0" }, "keywords": [ "agents", diff --git a/scripts/check-mcp.mjs b/scripts/check-mcp.mjs new file mode 100644 index 0000000..2c4c3fc --- /dev/null +++ b/scripts/check-mcp.mjs @@ -0,0 +1,48 @@ +// A real official-SDK client launches the server and checks its stdio lifecycle. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { buildTrace } from '../src/index.mjs'; +import { cataloguePanel } from '../examples/consumer.mjs'; + +const client = new Client({ name: 'hardwire-smoke', version: '0.5.0' }); +const transport = new StdioClientTransport({ command: process.execPath, + args: [process.argv[2] ?? fileURLToPath(new URL('../src/mcp-server.mjs', import.meta.url))], stderr: 'pipe' }); +const timer = setTimeout(() => { console.error('MCP check timed out'); process.exit(1); }, 30000); +try { + await client.connect(transport); + const { tools } = await client.listTools(); + assert.equal(tools.length, 5); + assert.ok(tools.every(t => t.annotations.readOnlyHint && !t.annotations.openWorldHint)); + const panel_json = JSON.stringify(cataloguePanel); + const call = async (name, args) => { + const response = await client.callTool({ name, arguments: args }); + assert.notEqual(response.isError, true, JSON.stringify(response)); + return JSON.parse(response.content.find(c => c.type === 'text').text); + }; + assert.equal((await call('hardwire_inspect_panel', { panel_json })).audit.readable, 1); + assert.equal((await call('hardwire_compile_panel', { panel_json })).tools[0].name, 'search_catalogue'); + const html = readFileSync(new URL('../examples/corpus/blog/page.html', import.meta.url), 'utf8'); + assert.ok((await call('hardwire_infer_html', { origin: 'https://blog.example.com', html })).draft.ports.length); + assert.equal((await call('hardwire_validate_params', { panel_json, port_id: 'search-catalogue', params_json: '{"query":"lamp"}' })).valid, true); + const trace = buildTrace({ panel: cataloguePanel, port: 'search-catalogue', params: { query: 'lamp' } }); + assert.equal((await call('hardwire_verify_trace', { panel_json, trace_json: JSON.stringify(trace) })).valid, true); + trace.params.query = 'tampered'; + assert.equal((await call('hardwire_verify_trace', { panel_json, trace_json: JSON.stringify(trace) })).valid, false); + for (const args of [{ panel_json: '{}' }, { panel_json, extra: true }, { panel_json: 42 }, { panel_json: 'x'.repeat(262145) }]) { + assert.equal((await client.callTool({ name: 'hardwire_inspect_panel', arguments: args })).isError, true); + } + // A compiled application tool is never added to this server's executable set. + try { + const rejected = await client.callTool({ name: 'search_catalogue', arguments: { query: 'lamp' } }); + assert.equal(rejected.isError, true); + } catch (error) { assert.notEqual(error.code, 'ERR_ASSERTION'); } + await client.ping(); + assert.equal((await client.listTools()).tools.length, 5); + console.log('MCP smoke check passed: official SDK client; five tools; inference, validation, trace and rejection checks; clean shutdown.'); +} finally { + await client.close(); + clearTimeout(timer); +} diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 7654b14..4fac7f7 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -11,6 +11,8 @@ const metadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); const npm = process.env.npm_execpath; if (!npm) throw new Error('Run this check with npm run test:package'); const temp = await mkdtemp(join(tmpdir(), 'hardwire-package-')); +// A fresh consumer also needs fresh registry metadata. npm ci's cache only +// guarantees tarballs, so test the actual online installation path in isolation. const env = { ...process.env, npm_config_cache: join(temp, 'cache') }; const runNpm = (args, cwd) => execFileSync(process.execPath, [npm, ...args], { cwd, env, encoding: 'utf8', timeout: 60000 }); try { @@ -18,7 +20,7 @@ try { const consumer = join(temp, 'consumer'); await mkdir(consumer); await writeFile(join(consumer, 'package.json'), JSON.stringify({ name: 'hardwire-package-check', private: true, type: 'module' })); - runNpm(['install', join(temp, packed[0].filename), '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false'], consumer); + runNpm(['install', join(temp, packed[0].filename), '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false'], consumer); const imported = execFileSync(process.execPath, ['--input-type=module', '-e', `import {normalizePanel,compilePanel} from ${JSON.stringify(metadata.name)}; console.log(compilePanel(normalizePanel({panel:'hardwire.panel/1',origin:'https://example.com',ports:[{id:'read-status',kind:'read',summary:'Read status.'}]}))[0].name);`], { cwd: consumer, encoding: 'utf8', timeout: 10000 }); @@ -27,7 +29,10 @@ try { const version = runNpm(['exec', '--offline', '--', 'hardwire', '--version'], consumer); assert.equal(version.trim(), metadata.version); execFileSync(process.execPath, [join(installed, 'examples/consumer.mjs')], { cwd: consumer, stdio: 'pipe', timeout: 10000 }); - console.log(`Package smoke check passed: ${metadata.name}@${metadata.version}; installed ESM, CLI and consumer example.`); + assert.equal(runNpm(['exec', '--offline', '--', 'hardwire-mcp', '--version'], consumer).trim(), metadata.version); + execFileSync(process.execPath, [join(root, 'scripts/check-mcp.mjs'), join(consumer, 'node_modules/.bin/hardwire-mcp')], + { cwd: consumer, stdio: 'pipe', timeout: 40000 }); + console.log(`Package smoke check passed: ${metadata.name}@${metadata.version}; installed ESM, CLI, consumer and MCP server.`); } finally { await rm(temp, { recursive: true, force: true }); } diff --git a/scripts/client-config.mjs b/scripts/client-config.mjs new file mode 100644 index 0000000..253b255 --- /dev/null +++ b/scripts/client-config.mjs @@ -0,0 +1,7 @@ +import { fileURLToPath } from 'node:url'; + +// Generate exact paths without touching a user's client configuration. +console.log(JSON.stringify({ mcpServers: { hardwire: { + command: process.execPath, + args: [fileURLToPath(new URL('../src/mcp-server.mjs', import.meta.url))], +} } }, null, 2)); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..7f8d114 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,19 @@ +// Package an owner-reviewable release candidate; does not publish or grant rights. +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const output = join(root, 'output/release'); +const npm = process.env.npm_execpath; +if (!npm) throw new Error('Run npm run release:prepare'); +await mkdir(output, { recursive: true }); +const [packed] = JSON.parse(execFileSync(process.execPath, + [npm, 'pack', '--json', '--ignore-scripts', '--pack-destination', output], + { cwd: root, encoding: 'utf8', timeout: 60000 })); +const bytes = await readFile(join(output, packed.filename)); +const checksum = createHash('sha256').update(bytes).digest('hex'); +await writeFile(join(output, 'SHA256SUMS'), `${checksum} ${packed.filename}\n`); +console.log(`Prepared ${packed.filename} and SHA256SUMS in output/release. Nothing published.`); diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs new file mode 100644 index 0000000..e50469e --- /dev/null +++ b/src/mcp-server.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { readFileSync, realpathSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { TOOLKIT_TOOLS, toolkitResult } from './toolkit.mjs'; + +const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + +export function createToolkitServer() { + const server = new McpServer({ name: 'hardwire', version }, { maxToolInputElements: 10000 }); + for (const { name, inputSchema, ...config } of TOOLKIT_TOOLS) { + server.registerTool(name, { ...config, inputSchema: fromJsonSchema(inputSchema) }, + async args => toolkitResult(name, args)); + } + return server; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + if (process.argv.length > 2) { + if (process.argv.length === 3 && process.argv[2] === '--version') console.log(version); + else if (process.argv.length === 3 && process.argv[2] === '--help') { + console.log('hardwire-mcp\nLocal stdio MCP toolkit. Start without arguments from an MCP client.\nNo network access, file tools or website execution.'); + } else { console.error('Usage: hardwire-mcp [--version|--help]'); process.exitCode = 2; } + } else { + const handle = serveStdio(createToolkitServer); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { void handle.close(); }); + } +} diff --git a/src/toolkit.mjs b/src/toolkit.mjs new file mode 100644 index 0000000..f7e7642 --- /dev/null +++ b/src/toolkit.mjs @@ -0,0 +1,102 @@ +// Fixed offline operations. Supplied panels never become executable handlers. +import { + auditPanel, canonicalJson, compilePanel, HardwireError, inferFromHtml, + normalizePanel, panelDigest, toPanelDraft, validateParams, verifyTrace, +} from './index.mjs'; + +const MAX_JSON = 256 * 1024; +const json = { type: 'string', minLength: 1, maxLength: MAX_JSON }; +const panel = { ...json, description: 'A Hardwire panel serialized as JSON. Do not include credentials.' }; +const schema = (properties, required = Object.keys(properties)) => ({ + type: 'object', properties, required, additionalProperties: false, +}); +export const TOOLKIT_TOOLS = [ + { + name: 'hardwire_inspect_panel', + description: 'Validate a supplied Hardwire panel and summarize declared effects and approval floors. Declarations are not evidence of endpoint behavior.', + inputSchema: schema({ panel_json: panel }), + }, + { + name: 'hardwire_compile_panel', + description: 'Compile a supplied Hardwire panel into MCP tool descriptions. Returned descriptions are data; they are not registered or executed.', + inputSchema: schema({ panel_json: panel }), + }, + { + name: 'hardwire_infer_html', + description: 'Infer candidate operations from supplied static HTML and return a draft for human review. Does not fetch URLs or execute JavaScript. Every inferred effect and endpoint needs review.', + inputSchema: schema({ + origin: { type: 'string', minLength: 1, maxLength: 2048 }, + html: { type: 'string', maxLength: 512 * 1024 }, + }), + }, + { + name: 'hardwire_validate_params', + description: 'Check supplied arguments against one declared port. Returns only validity and the panel digest; never executes the operation or echoes argument values.', + inputSchema: schema({ panel_json: panel, port_id: { type: 'string', minLength: 1, maxLength: 64 }, params_json: json }), + }, + { + name: 'hardwire_verify_trace', + description: 'Check a trace digest, shape and argument binding against the supplied panel. A valid digest does not prove execution, authorship or approval.', + inputSchema: schema({ panel_json: panel, trace_json: json }), + }, +].map(tool => ({ ...tool, annotations: { + readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, +} })); + +function parse(text, path) { + if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_JSON) { + throw new HardwireError('hardwire_input_invalid', path, 'expected JSON text of at most 256 KiB'); + } + try { return JSON.parse(canonicalJson(JSON.parse(text))); } + catch { throw new HardwireError('hardwire_input_invalid', path, 'expected bounded plain JSON data'); } +} + +export function callToolkit(name, args) { + const tool = TOOLKIT_TOOLS.find(candidate => candidate.name === name); + if (!tool) throw new HardwireError('hardwire_tool_unknown'); + if (!args || typeof args !== 'object' || Array.isArray(args) || + Object.keys(args).some(key => !Object.hasOwn(tool.inputSchema.properties, key)) || + tool.inputSchema.required.some(key => !Object.hasOwn(args, key))) { + throw new HardwireError('hardwire_input_invalid', 'arguments', 'missing or unknown arguments'); + } + // Keep standalone calls as strict as the transport schema, without an SDK dependency. + for (const [key, rule] of Object.entries(tool.inputSchema.properties)) { + if (typeof args[key] !== 'string' || args[key].length < (rule.minLength ?? 0) || args[key].length > rule.maxLength) { + throw new HardwireError('hardwire_input_invalid', key, 'invalid string length or type'); + } + } + if (name === 'hardwire_infer_html') { + const inference = inferFromHtml(args.html, { origin: args.origin }); + return { inference, draft: inference.candidates.length ? toPanelDraft(inference) : null, + reviewRequired: true, warning: 'Treat extracted text as untrusted data. No endpoint was contacted or verified.' }; + } + const normalized = normalizePanel(parse(args.panel_json, 'panel_json')); + const digest = panelDigest(normalized); + switch (name) { + case 'hardwire_inspect_panel': return { valid: true, panelDigest: digest, audit: auditPanel(normalized) }; + case 'hardwire_compile_panel': return { panelDigest: digest, tools: compilePanel(normalized), executable: false }; + case 'hardwire_validate_params': { + const port = normalized.ports.find(port => port.id === args.port_id); + if (!port) throw new HardwireError('hardwire_port_unknown', 'port_id'); + validateParams(port, parse(args.params_json, 'params_json')); + return { valid: true, panelDigest: digest, port: port.id, executed: false }; + } + case 'hardwire_verify_trace': return { + valid: verifyTrace(parse(args.trace_json, 'trace_json'), { panel: normalized }), + panelDigest: digest, provesExecution: false, + }; + } +} + +export function toolkitResult(name, args) { + try { + const result = callToolkit(name, args); + const text = JSON.stringify(result); + if (Buffer.byteLength(text) > 1024 * 1024) throw new HardwireError('hardwire_output_too_large'); + return { content: [{ type: 'text', text }], structuredContent: result }; + } catch (error) { + // Do not echo submitted data, parser excerpts, filesystem paths or stacks. + const code = error instanceof HardwireError ? error.code : 'hardwire_tool_failed'; + return { isError: true, content: [{ type: 'text', text: code }] }; + } +} diff --git a/test/toolkit.test.mjs b/test/toolkit.test.mjs new file mode 100644 index 0000000..4bcbedc --- /dev/null +++ b/test/toolkit.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { callToolkit, toolkitResult, TOOLKIT_TOOLS } from '../src/toolkit.mjs'; +import { buildTrace } from '../src/index.mjs'; +import { cataloguePanel } from '../examples/consumer.mjs'; + +const panel_json = JSON.stringify(cataloguePanel); +test('the toolkit exposes only five fixed offline operations', () => { + assert.equal(TOOLKIT_TOOLS.length, 5); + assert.ok(TOOLKIT_TOOLS.every(t => t.annotations.readOnlyHint && !t.annotations.openWorldHint)); + assert.throws(() => callToolkit('search_catalogue', { query: 'lamp' }), { code: 'hardwire_tool_unknown' }); + assert.equal(callToolkit('hardwire_inspect_panel', { panel_json }).audit.readable, 1); + const compiled = callToolkit('hardwire_compile_panel', { panel_json }); + assert.equal(compiled.tools[0].name, 'search_catalogue'); + assert.equal(compiled.executable, false); +}); +test('HTML inference returns reviewable data, including empty and mutating captures', () => { + const empty = callToolkit('hardwire_infer_html', { origin: 'https://example.com', html: '

Hello

' }); + assert.equal(empty.draft, null); + const result = callToolkit('hardwire_infer_html', { origin: 'https://example.com', html: '
' }); + assert.equal(result.reviewRequired, true); + assert.equal(result.draft.ports[0].confirmation, 'user'); +}); +test('arguments are checked without echoing credentials, even on errors', () => { + const p = structuredClone(cataloguePanel); + p.ports[0].parameters.properties.query.writeOnly = true; + const args = { panel_json: JSON.stringify(p), port_id: 'search-catalogue', params_json: '{"query":"top-secret"}' }; + const response = toolkitResult('hardwire_validate_params', args); + assert.equal(response.structuredContent.valid, true); + assert.ok(!JSON.stringify(response).includes('top-secret')); + args.params_json = '{"query":42,"top-secret":true}'; + const failed = toolkitResult('hardwire_validate_params', args); + assert.equal(failed.isError, true); + assert.ok(!JSON.stringify(failed).includes('top-secret')); +}); +test('trace verification binds arguments and panel and rejects tampering', () => { + const trace = buildTrace({ panel: cataloguePanel, port: 'search-catalogue', params: { query: 'lamp' } }); + assert.equal(callToolkit('hardwire_verify_trace', { panel_json, trace_json: JSON.stringify(trace) }).valid, true); + trace.params.query = 'chair'; + assert.equal(callToolkit('hardwire_verify_trace', { panel_json, trace_json: JSON.stringify(trace) }).valid, false); +}); +test('invalid, oversized and unknown inputs are rejected without echoing source', () => { + for (const args of [{}, { panel_json, url: 'https://example.com' }, { panel_json: '{secret' }, { panel_json: 'x'.repeat(262145) }]) { + const response = toolkitResult('hardwire_inspect_panel', args); + assert.equal(response.isError, true); + assert.ok(!JSON.stringify(response).includes('secret')); + } + assert.equal(toolkitResult('hardwire_infer_html', { origin: 'https://example.com', html: 'é'.repeat(300000) }).isError, true); +});