diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c2cbff6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +# The Dockerfile downloads a release and copies nothing from the repository. +* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2788b8..61a3e1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,8 @@ jobs: CORE: '^(core/|skills/|\.github/workflows/ci\.yml$)' MACOS: '^(apps/macos/|scripts/build-core\.sh$)' DESKTOP: '^(apps/desktop/|scripts/(test-updater-scripts|updater-manifest|appcast|sparkle-public-key)\.sh$)' - # The package's tests read the skill and the site's install links. - CLI: '^(packages/cli/|skills/|site/src/lib/(site|release)\.ts$|site/src/components/InstallDialog\.astro$|site/astro\.config\.mjs$|\.github/workflows/ci\.yml$)' + # The package's tests read the skill, the site's install links and the MCP Registry's server.json. + CLI: '^(packages/cli/|skills/|server\.json$|\.mcp\.json$|site/src/lib/(site|release)\.ts$|site/src/components/InstallDialog\.astro$|site/astro\.config\.mjs$|\.github/workflows/ci\.yml$)' run: | if [[ $GITHUB_EVENT_NAME == pull_request ]]; then files=$(git diff --name-only HEAD^1 HEAD) diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..92ff5e8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "localhost": { + "command": "npx", + "args": ["-y", "@getlocalhost/cli", "mcp", "serve"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index f869f89..0b3a26d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,12 +37,14 @@ apps/macos/ the macOS app apps/desktop/ the Linux and Windows app (Tauri 2) src/ React + TypeScript UI; src/bindings.ts is generated, never edited src-tauri/ Rust shell hosting the core -packages/cli/ `npx @getlocalhost/cli mcp install`: connects agents from a terminal (Node, no dependencies) +packages/cli/ `npx @getlocalhost/cli mcp install`: connects agents from a terminal; `mcp serve` runs the app's server (Node, no dependencies) skills/ agent skills, e.g. using localhost's MCP server site/ the website (Astro): landing page, docs, changelog; reads docs/ and the desktop tokens scripts/ build-core.sh, build-release.sh, signing and installer artwork packaging/homebrew/ the Homebrew cask's template; the tap is marcellosso/homebrew-tap +assets/ brand images for listings, e.g. the 400×400 logo docs/configuration.md the Project file format (user-facing) +server.json, glama.json MCP directory listings, with the example .mcp.json and the introspection-only Dockerfile ``` ## Vocabulary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63e0ddf..6f69624 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ Checking the app for Windows from macOS or Linux works like the core's check abo 2. Run the checks for what you touched (AGENTS.md, Verifying a change), then build and run the app. 3. Open a PR describing what changed and why, how you checked it, and anything you left out. Link the issue it addresses. -CI runs the core's tests on Linux, macOS and Windows, the Swift layer's tests, an unsigned app build, and the desktop app's checks (TypeScript, Vitest, clippy, its Rust tests, up-to-date bindings), and the npm package's tests; all must pass. On a PR each job runs only when files it depends on changed, and is skipped otherwise: the core's jobs for `core/` and `skills/` (the core embeds the localhost-mcp skill), the macOS job for those and `apps/macos/`, the desktop job for those and `apps/desktop/`, the npm package's (`packages/cli/`, on Linux, macOS and Windows) for it, `skills/` and the site's install links, and each for the scripts it runs. The site builds only for `site/`, `docs/`, `.github/assets/` and the desktop tokens, and Vercel's preview of a PR follows the same paths (`site/scripts/ignore-build.sh`), so other PRs show it as Ignored; production on `main` and the release's deploy hook always build. Changing a workflow runs everything in it, and every push to `main` runs everything; release packages are built only by a release tag, and the npm package is published only by running its workflow by hand. A skipped job counts as passing, and the `CI result` check fails if any CI job failed. The macOS job also skips draft PRs (macOS minutes cost the most), so mark yours ready for review when you want the full run. CI on a PR from a fork starts once a maintainer approves the run, and every PR needs a maintainer's approving review before it merges. Commits are kept as written (merge commits, no squash), so make each commit message say what it does in one line. +CI runs the core's tests on Linux, macOS and Windows, the Swift layer's tests, an unsigned app build, and the desktop app's checks (TypeScript, Vitest, clippy, its Rust tests, up-to-date bindings), and the npm package's tests; all must pass. On a PR each job runs only when files it depends on changed, and is skipped otherwise: the core's jobs for `core/` and `skills/` (the core embeds the localhost-mcp skill), the macOS job for those and `apps/macos/`, the desktop job for those and `apps/desktop/`, the npm package's (`packages/cli/`, on Linux, macOS and Windows) for it, `skills/`, the root `server.json` and `.mcp.json`, and the site's install links, and each for the scripts it runs. The site builds only for `site/`, `docs/`, `.github/assets/` and the desktop tokens, and Vercel's preview of a PR follows the same paths (`site/scripts/ignore-build.sh`), so other PRs show it as Ignored; production on `main` and the release's deploy hook always build. Changing a workflow runs everything in it, and every push to `main` runs everything; release packages are built only by a release tag, and the npm package is published only by running its workflow by hand. A skipped job counts as passing, and the `CI result` check fails if any CI job failed. The macOS job also skips draft PRs (macOS minutes cost the most), so mark yours ready for review when you want the full run. CI on a PR from a fork starts once a maintainer approves the run, and every PR needs a maintainer's approving review before it merges. Commits are kept as written (merge commits, no squash), so make each commit message say what it does in one line. Releases are cut by tagging `main`; see [RELEASING.md](RELEASING.md). @@ -130,7 +130,7 @@ By contributing, you agree your contributions are licensed under the MIT license `@getlocalhost/cli` (`packages/cli/`) is published to npm from CI by the [Publish the npm package](.github/workflows/publish-cli.yml) workflow, run by hand. npm trusts that workflow through trusted publishing, so there is no npm token. With each app release: -1. Bump `version` in `packages/cli/package.json` and merge it into `main`. npm never accepts a version twice. +1. Bump `version` in `packages/cli/package.json`, and both versions in the root `server.json` to match (the package's tests check it), and merge it into `main`. npm never accepts a version twice. 2. Run the workflow on `main`, first as a dry run: ```sh @@ -139,3 +139,15 @@ By contributing, you agree your contributions are licensed under the MIT license ``` It runs the package's tests, fails if the version is already on npm, checks the package includes the skill, and publishes it with provenance. A dry run does all but the publishing, from any branch; publishing runs only from `main`. + +## Publishing to the MCP Registry + +The root `server.json` lists `@getlocalhost/cli` in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.marcellosso/localhost`, run as `npx -y @getlocalhost/cli mcp serve`. The registry checks that npm has that version and that its `package.json` names the server in `mcpName`, so publish it after the npm package, with [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/releases) (`brew install mcp-publisher`): + +```sh +mcp-publisher validate +mcp-publisher login github # as marcellosso, who owns the io.github.marcellosso namespace +mcp-publisher publish +``` + +The other files at the root are for MCP directories too: `glama.json` names the maintainers for Glama, the `Dockerfile` lets directories that inspect a server's tools run `localhost mcp` from the latest release, and `.mcp.json` is the example configuration. Claude Code reads that `.mcp.json` as a project server: it asks before using it, and once allowed it replaces a user-scope `localhost` server of the same name in this repository. Decline it, or list it in `disabledMcpjsonServers` in `.claude/settings.local.json`, to keep the app's own entry. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a45052b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# For MCP directories' introspection only, such as Glama's: it runs +# `localhost mcp` so they can list its tools. Inside a container it sees no +# Projects and can't start or stop anything on your computer: for real use, +# install the app (https://www.getlocalhost.app) and connect your agents with +# `npx -y @getlocalhost/cli mcp install`. +# +# The `localhost` command from release VERSION's .deb; VERSION=latest takes the latest release: +# docker build --platform=linux/amd64 -t localhost-mcp . +# docker build --platform=linux/amd64 --build-arg VERSION=latest -t localhost-mcp . +# The Linux app is amd64 only. +# +# Bump VERSION with each release, so directories list that release's tools. +FROM debian:bookworm-slim + +ARG VERSION=0.4.0 +RUN set -eu; \ + apt-get update; \ + apt-get install -y --no-install-recommends curl ca-certificates; \ + if [ "$VERSION" = latest ]; then \ + VERSION=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/marcellosso/localhost/releases/latest | sed 's|.*/tag/v||'); \ + fi; \ + curl -fsSL -o /tmp/localhost.deb "https://github.com/marcellosso/localhost/releases/download/v${VERSION}/localhost-app_${VERSION}_amd64.deb"; \ + dpkg-deb -x /tmp/localhost.deb /tmp/localhost; \ + install -m 0755 /tmp/localhost/usr/bin/localhost /usr/local/bin/localhost; \ + localhost --version; \ + apt-get purge -y --auto-remove curl; \ + rm -rf /tmp/localhost /tmp/localhost.deb /var/lib/apt/lists/* + +ENTRYPOINT ["localhost", "mcp"] diff --git a/README.md b/README.md index 663b5ae..8ff4a77 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,46 @@ It writes the same entries as **Connect** for every agent it finds, and Claude D Other MCP clients take the same `command` and `args`. On Windows it covers Projects on Windows itself; a Project in a WSL distro runs only from the app, and agents installed inside a WSL distro are set up by hand. The skill is in [skills/localhost-mcp/SKILL.md](skills/localhost-mcp/SKILL.md): copy the `localhost-mcp` folder to `~/.claude/skills/`, `~/.cursor/skills/` or `~/.agents/skills/`. More in the [MCP docs](https://www.getlocalhost.app/docs/mcp), the [skill docs](https://www.getlocalhost.app/docs/skills) and [localhost for AI coding agents](https://www.getlocalhost.app/agents). +### How do I add the localhost MCP server to Claude Code, Cursor or Codex? + +localhost app's MCP server, `localhost mcp`, is a stdio Model Context Protocol server that lets Claude Code, Cursor and Codex start, stop and read the logs of your local dev servers on macOS, Windows and Linux. + +`npx -y @getlocalhost/cli mcp serve` runs `localhost mcp` from any MCP client's configuration: it finds the command that comes with the app and hands it the client's stdin and stdout (`mcp serve --read-only` runs `localhost mcp --read-only`). It needs the localhost app: install it from [getlocalhost.app](https://www.getlocalhost.app). Without it, the server exits and says on stderr how to install it. + +Connect every agent on this computer at once: + +```sh +npx -y @getlocalhost/cli mcp install +npx -y @getlocalhost/cli mcp install --read-only # without the tools that add, edit or remove +``` + +Or add the server to one agent: + +- **Claude Code**: `claude mcp add --scope user localhost -- npx -y @getlocalhost/cli mcp serve` +- **Codex**: `codex mcp add localhost -- npx -y @getlocalhost/cli mcp serve`, or in `~/.codex/config.toml`: + + ```toml + [mcp_servers.localhost] + command = "npx" + args = ["-y", "@getlocalhost/cli", "mcp", "serve"] + ``` + +- **Cursor, Claude Desktop and other clients**: in `~/.cursor/mcp.json` (or `.cursor/mcp.json` in one project), `claude_desktop_config.json`, or the client's own file: + + ```json + { + "mcpServers": { + "localhost": { "command": "npx", "args": ["-y", "@getlocalhost/cli", "mcp", "serve"] } + } + } + ``` + +Tools: `list_projects`, `list_services`, `status`, `start_service`, `stop_service`, `start_project`, `stop_project`, `logs`, `ports`, `create_project`, `update_project`, `delete_project`, `create_service`, `update_service` and `delete_service`; `--read-only` leaves out the last six. The six that add, edit or remove need the localhost app 0.4.0 or later. + +[`server.json`](server.json) describes this server for the [MCP Registry](https://registry.modelcontextprotocol.io), and [`.mcp.json`](.mcp.json) is the configuration above. The [`Dockerfile`](Dockerfile) is only for MCP directories that inspect a server's tools: in a container, `localhost mcp` sees no Projects and can't start or stop anything on your computer. + + + ## FAQ ### What is localhost app? diff --git a/assets/logo-400.png b/assets/logo-400.png new file mode 100644 index 0000000..802f9cd Binary files /dev/null and b/assets/logo-400.png differ diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..c8f03aa --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["marcellosso"] +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 52933a7..2666943 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -27,16 +27,31 @@ It writes what **Settings → Agents** in the app writes, and the app shows thes npx @getlocalhost/cli mcp install # add the server and the skill npx @getlocalhost/cli mcp uninstall # remove them, and nothing else npx @getlocalhost/cli mcp status # where each agent stands +npx @getlocalhost/cli mcp serve # run the server, for an MCP client's configuration ``` | Option | | |---|---| | `--client ` | `claude-code`, `claude-desktop`, `cursor` or `codex`; `claude` for both Claude apps. Repeat it, or separate with commas, for more than one. Without it: every agent found on this computer. | -| `--read-only` | The server runs `localhost mcp --read-only`, without the tools that add, edit or remove Projects and Services. Needs a localhost app that has it (from the release after 0.3.0). Installing again without it keeps a read-only server read-only. | +| `--read-only` | For `mcp install` and `mcp serve`. The server runs `localhost mcp --read-only`, without the tools that add, edit or remove Projects and Services. Needs a localhost app that has it (0.4.0 or later). Installing again without it keeps a read-only server read-only. | | `--dry-run` | Prints what would change, as a diff, and changes nothing. | Then start a new Claude Code session, quit and reopen Claude Desktop, check **Cursor Settings → MCP**, or restart Codex. +## Any MCP client + +`mcp serve` is the server itself, for any client that starts MCP servers over stdio: + +```json +{ + "mcpServers": { + "localhost": { "command": "npx", "args": ["-y", "@getlocalhost/cli", "mcp", "serve"] } + } +} +``` + +It finds the app's `localhost` command as `mcp install` does and runs `localhost mcp` with the client's stdin and stdout, passing on Ctrl-C and other signals and exiting as the server does. `mcp serve --read-only` runs `localhost mcp --read-only`. It writes nothing to stdout itself, since that is the client's channel: without the app, or with an app whose command doesn't run, it says why on stderr and exits with 1. `claude mcp add --scope user localhost -- npx -y @getlocalhost/cli mcp serve` and `codex mcp add localhost -- npx -y @getlocalhost/cli mcp serve` add it from a terminal. + More in the [MCP docs](https://www.getlocalhost.app/docs/mcp). Node 18 or later; no dependencies. ## Development diff --git a/packages/cli/bin/getlocalhost.js b/packages/cli/bin/getlocalhost.js index 387827a..a56bbb9 100755 --- a/packages/cli/bin/getlocalhost.js +++ b/packages/cli/bin/getlocalhost.js @@ -1,4 +1,4 @@ #!/usr/bin/env node import { main } from '../src/cli.js'; -process.exitCode = main(process.argv.slice(2)); +process.exitCode = await main(process.argv.slice(2)); diff --git a/packages/cli/package.json b/packages/cli/package.json index 8947d25..e7277d7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,8 @@ { "name": "@getlocalhost/cli", - "version": "0.1.0", - "description": "Connect Claude Code, Claude Desktop, Cursor and Codex to localhost mcp, the MCP server of the localhost app, from a terminal.", + "version": "0.2.0", + "mcpName": "io.github.marcellosso/localhost", + "description": "Connect Claude Code, Claude Desktop, Cursor and Codex to localhost mcp, the MCP server of the localhost app, from a terminal, or run it from any MCP client with npx.", "keywords": [ "localhost", "mcp", diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js index 6a886b4..74d5613 100644 --- a/packages/cli/src/cli.js +++ b/packages/cli/src/cli.js @@ -1,6 +1,8 @@ // `npx @getlocalhost/cli mcp install|uninstall|status`: connects coding agents // to `localhost mcp` from a terminal, as Settings → Agents does in the app. +// `mcp serve` runs that server itself, for MCP clients configured with npx. +import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -20,13 +22,15 @@ Commands: to your agents. Every other server and setting stays. mcp uninstall Remove them, and nothing else mcp status Show where each agent stands + mcp serve Run the app's localhost mcp on stdin and stdout, for any + MCP client: npx -y @getlocalhost/cli mcp serve Options: --client claude-code, claude-desktop, cursor or codex; claude for both Claude apps. Repeat for more than one. Without it: every agent found on this computer --read-only Leave out the tools that add, edit or remove Projects and - Services (localhost mcp --read-only) + Services (localhost mcp --read-only). For install and serve --dry-run Print what would change, and change nothing -h, --help Show this -V, --version Show this package's version @@ -63,16 +67,18 @@ export function parse(argv) { } else if (argument.startsWith('-')) return { error: `Unknown option ${argument}.` }; else words.push(argument); } - if (words[0] !== 'mcp' || words.length !== 2 || !['install', 'uninstall', 'status'].includes(words[1])) { + if (words[0] !== 'mcp' || words.length !== 2 || !['install', 'uninstall', 'status', 'serve'].includes(words[1])) { return { error: words.length === 0 ? USAGE : `Unknown command “${words.join(' ')}”.\n\n${USAGE}` }; } - if (options.readOnly && words[1] !== 'install') return { error: '--read-only is for mcp install.' }; + if (options.readOnly && !['install', 'serve'].includes(words[1])) return { error: '--read-only is for mcp install and mcp serve.' }; + if (words[1] === 'serve' && (options.clients.length > 0 || options.dryRun)) return { error: 'mcp serve takes only --read-only.' }; return { command: words[1], ...options }; } /** - * Runs the command line, writing to `io.out` and `io.err`; returns the exit code. `io` also carries what tests replace: `platform`, `home`, - * `env`, and the `candidates`, `probe`, `spotlight`, `claude` and `runClaude` of `findApp` and `Agents`. + * Runs the command line, writing to `io.out` and `io.err`; returns the exit code, or for `mcp serve` a promise of it. `io` also carries what + * tests replace: `platform`, `home`, `env`, the `candidates`, `probe`, `spotlight`, `claude` and `runClaude` of `findApp` and `Agents`, and + * the `spawn`, `signals` and `raise` of `serve`. */ export function main(argv, io = {}) { const out = io.out ?? ((line) => process.stdout.write(`${line}\n`)); @@ -92,6 +98,7 @@ export function main(argv, io = {}) { const env = io.env ?? process.env; const context = { platform, home, env, probe: io.probe, spotlight: io.spotlight, candidates: io.candidates }; const app = findApp(context); + if (parsed.command === 'serve') return serve(app, parsed, { ...io, err, platform, env }); const files = new Files({ dryRun: parsed.dryRun }); const agents = new Agents({ ...context, @@ -168,6 +175,44 @@ export function main(argv, io = {}) { return failed ? 1 : 0; } +const FORWARDED = ['SIGINT', 'SIGTERM', 'SIGHUP']; + +/** + * Runs the app's `localhost mcp` with this process's stdin and stdout, which are the client's JSON-RPC channel: nothing else may write to + * stdout, so every problem goes to stderr. Resolves to the exit code to leave with, once the server has ended. + */ +function serve(app, parsed, { err, platform, env, spawn: start = spawn, signals = process, raise = (signal) => process.kill(process.pid, signal) }) { + const refusal = refuse(app, parsed, platform, env); + if (refusal) { + refusal.forEach((line) => err(line)); + return Promise.resolve(1); + } + return new Promise((resolve) => { + const child = start(app.command, ['mcp', ...(parsed.readOnly ? ['--read-only'] : [])], { stdio: 'inherit', windowsHide: true }); + // On Windows the server shares the console, which sends it Ctrl-C itself, and a forwarded signal would only terminate it. Listening + // keeps this process alive until the server has ended, either way. + const forward = (signal) => platform !== 'win32' && child.kill(signal); + for (const signal of FORWARDED) signals.on(signal, forward); + let done = false; + const finish = (code) => { + if (done) return; + done = true; + for (const signal of FORWARDED) signals.off(signal, forward); + resolve(code); + }; + child.on('error', (error) => { + err(`Couldn’t run ${app.command}: ${error.message}`); + finish(1); + }); + child.on('exit', (code, signal) => { + if (signal === null) return finish(code ?? 1); + // Ended the way the server did, so whoever started this sees the signal; 128 + its number if it doesn't end this process. + finish(128 + (os.constants.signals[signal] ?? 0)); + raise(signal); + }); + }); +} + /** What happened to one agent, in a sentence. */ function summary(parsed, agents, agent, before, { server, changed }) { const where = display(agent.config, agents.home); diff --git a/packages/cli/test/fixture.js b/packages/cli/test/fixture.js index 2893a6c..0133a31 100644 --- a/packages/cli/test/fixture.js +++ b/packages/cli/test/fixture.js @@ -11,9 +11,9 @@ export const SKILL = '---\nname: localhost-mcp\ndescription: test\n---\n\nUse lo /** * `agents`: the agents' folders to create. `app`: whether the app is installed; `readOnly`: whether its command has `mcp --read-only`. - * `claude`: the `claude` command Claude Code is changed with (`null`: edit the file). + * `claude`: the `claude` command Claude Code is changed with (`null`: edit the file). `io`: more of `main`'s `io`, such as `serve`'s `spawn`. */ -export function fixture({ agents = ['.claude', '.cursor', '.codex'], app = true, readOnly = true, claude = null, platform = process.platform } = {}) { +export function fixture({ agents = ['.claude', '.cursor', '.codex'], app = true, readOnly = true, claude = null, platform = process.platform, io = {} } = {}) { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'getlocalhost-')); const env = { PATH: '', @@ -39,7 +39,7 @@ export function fixture({ agents = ['.claude', '.cursor', '.codex'], app = true, file, read: (...parts) => (fs.existsSync(file(...parts)) ? fs.readFileSync(file(...parts), 'utf8') : null), write: (parts, text) => write(file(...parts), text), - /** Runs the command line; `{ code, out, err }`, each a string. */ + /** Runs the command line; `{ code, out, err }`, each a string, or for `mcp serve` a promise of it. */ run(...argv) { const out = []; const err = []; @@ -60,8 +60,10 @@ export function fixture({ agents = ['.claude', '.cursor', '.codex'], app = true, runClaude: (claudeCommand, args) => claudeCalls.push([claudeCommand, ...args]), out: (line) => out.push(line), err: (line) => err.push(line), + ...io, }); - return { code, out: out.join('\n'), err: err.join('\n') }; + const result = (exit) => ({ code: exit, out: out.join('\n'), err: err.join('\n') }); + return code instanceof Promise ? code.then(result) : result(code); }, }; } diff --git a/packages/cli/test/serve.test.js b/packages/cli/test/serve.test.js new file mode 100644 index 0000000..e2de307 --- /dev/null +++ b/packages/cli/test/serve.test.js @@ -0,0 +1,188 @@ +// `mcp serve`: runs the app's `localhost mcp` for MCP clients started with +// npx, keeping stdout for the server alone. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { parse } from '../src/cli.js'; +import { fixture } from './fixture.js'; + +/** A `spawn` that records its calls and hands back a server the test ends, and the signals sent to it. */ +function fakeSpawn() { + const calls = []; + const spawnServer = (command, args, options) => { + const child = new EventEmitter(); + child.killed = []; + child.kill = (signal) => child.killed.push(signal); + calls.push({ command, args, options, child }); + return child; + }; + return { calls, spawn: spawnServer }; +} + +/** + * Runs `mcp serve` as on `platform`, with a fake `spawn`, signals and re-raise. The fixture's command is where this computer's app would + * put it, a real path here, and is the one candidate, so only what `serve` does differs by `platform`. + */ +function serve({ argv = [], app = true, readOnly = true, platform = 'linux' } = {}) { + const spawned = fakeSpawn(); + const signals = new EventEmitter(); + const raised = []; + const io = { platform, candidates: () => [home.command], spawn: spawned.spawn, signals, raise: (signal) => raised.push(signal) }; + const home = fixture({ app, readOnly, io }); + const running = home.run('mcp', 'serve', ...argv); + return { home, running, calls: spawned.calls, signals, raised }; +} + +test('parses mcp serve, and --read-only only for install and serve', () => { + assert.deepEqual(parse(['mcp', 'serve']), { command: 'serve', clients: [], readOnly: false, dryRun: false }); + assert.deepEqual(parse(['mcp', 'serve', '--read-only']), { command: 'serve', clients: [], readOnly: true, dryRun: false }); + assert.equal(parse(['mcp', 'install', '--read-only']).readOnly, true); + for (const command of ['uninstall', 'status']) assert.match(parse(['mcp', command, '--read-only']).error, /--read-only is for mcp install and mcp serve/); + assert.match(parse(['mcp', 'serve', '--client', 'cursor']).error, /only --read-only/); + assert.match(parse(['mcp', 'serve', '--dry-run']).error, /only --read-only/); +}); + +test('runs the app’s localhost mcp with the client’s stdin and stdout', async () => { + const { home, running, calls } = serve(); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, home.command); + assert.deepEqual(calls[0].args, ['mcp']); + assert.deepEqual(calls[0].options, { stdio: 'inherit', windowsHide: true }); + calls[0].child.emit('exit', 0, null); + assert.deepEqual(await running, { code: 0, out: '', err: '' }); +}); + +test('passes --read-only on', async () => { + const { running, calls } = serve({ argv: ['--read-only'] }); + assert.deepEqual(calls[0].args, ['mcp', '--read-only']); + calls[0].child.emit('exit', 0, null); + assert.equal((await running).code, 0); +}); + +test('without the app, says how to install it on stderr only, and fails', async () => { + for (const platform of ['darwin', 'win32', 'linux']) { + const { running, calls } = serve({ app: false, platform }); + const { code, out, err } = await running; + assert.equal(code, 1); + assert.equal(out, '', platform); + assert.match(err, /The localhost app isn’t installed/); + assert.match(err, /https:\/\/www\.getlocalhost\.app/); + assert.equal(calls.length, 0); + } +}); + +test('--read-only fails when the app’s command doesn’t have it', async () => { + const { running, calls } = serve({ argv: ['--read-only'], readOnly: false }); + const { code, out, err } = await running; + assert.equal(code, 1); + assert.equal(out, ''); + assert.match(err, /localhost 1\.2\.3 has no `localhost mcp --read-only` yet/); + assert.equal(calls.length, 0); +}); + +test('exits with the server’s exit code', async () => { + const { running, calls } = serve(); + calls[0].child.emit('exit', 3, null); + assert.equal((await running).code, 3); +}); + +test('forwards SIGINT, SIGTERM and SIGHUP to the server, and stops listening once it ends', async () => { + const { running, calls, signals } = serve(); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.emit(signal, signal); + assert.deepEqual(calls[0].child.killed, ['SIGINT', 'SIGTERM', 'SIGHUP']); + calls[0].child.emit('exit', 0, null); + await running; + assert.equal(signals.listenerCount('SIGTERM'), 0); +}); + +test('on Windows, leaves Ctrl-C to the console the server shares', async () => { + const { running, calls, signals } = serve({ platform: 'win32' }); + signals.emit('SIGINT', 'SIGINT'); + assert.deepEqual(calls[0].child.killed, []); + calls[0].child.emit('exit', 0, null); + assert.equal((await running).code, 0); +}); + +test('ends the way the server did when a signal killed it', async () => { + const { running, calls, raised } = serve(); + calls[0].child.emit('exit', null, 'SIGTERM'); + assert.equal((await running).code, 128 + os.constants.signals.SIGTERM); + assert.deepEqual(raised, ['SIGTERM']); +}); + +test('a server that can’t be started is an error on stderr', async () => { + const { home, running, calls } = serve(); + calls[0].child.emit('error', new Error('spawn EACCES')); + const { code, out, err } = await running; + assert.equal(code, 1); + assert.equal(out, ''); + assert.equal(err, `Couldn’t run ${home.command}: spawn EACCES`); +}); + +/** Runs `node -e script -- ...argv` in `cwd`, writing `input` to its stdin: `{ code, out, err }`. */ +async function runNode(script, argv, { cwd, input }) { + const child = spawn(process.execPath, ['--input-type=module', '-e', script, '--', ...argv], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + let out = ''; + let err = ''; + child.stdout.on('data', (chunk) => (out += chunk)); + child.stderr.on('data', (chunk) => (err += chunk)); + child.stdin.end(input); + const code = await new Promise((resolve) => child.on('close', resolve)); + return { code, out, err }; +} + +const cli = new URL('../src/cli.js', import.meta.url).href; +const request = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n'; + +// A real server on every OS: Node itself as the app's command, which `mcp` makes run the script named mcp in the working folder. It echoes +// stdin and exits with 3 once stdin ends. +test('end to end: runs the server with the client’s stdin and stdout, and exits as it does', async () => { + const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'getlocalhost-serve-')); + fs.writeFileSync( + path.join(folder, 'mcp'), + "process.stderr.write(`args: ${['mcp', ...process.argv.slice(2)].join(' ')}\\n`);\nprocess.stdin.on('end', () => (process.exitCode = 3));\nprocess.stdin.pipe(process.stdout);\n", + ); + const script = `import { main } from ${JSON.stringify(cli)}; +const probe = (command, args) => ({ output: args[0] === '--version' ? 'localhost 9.9.9\\n' : ' mcp [--read-only] Serve Projects\\n' }); +process.exitCode = await main(['mcp', 'serve', ...process.argv.slice(1)], { candidates: () => [process.execPath], spotlight: () => [], probe });`; + for (const argv of [[], ['--read-only']]) { + const { code, out, err } = await runNode(script, argv, { cwd: folder, input: request }); + assert.equal(code, 3, err); + assert.equal(out, request); + assert.equal(err.trim(), `args: ${['mcp', ...argv].join(' ')}`); + } +}); + +// The app's command as findApp checks it: a `localhost` script that answers --version and --help as the app's does, and echoes stdin as `mcp`. +test('end to end: finds and runs a localhost command', { skip: process.platform === 'win32' && 'the fake command is a shell script; the test above runs on Windows' }, async () => { + const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'getlocalhost-serve-')); + const command = path.join(folder, 'localhost'); + fs.writeFileSync( + command, + [ + '#!/bin/sh', + 'case "$1" in', + ' --version) echo "localhost 9.9.9" ;;', + ' --help) echo " mcp [--read-only] Serve Projects" ;;', + ' mcp) echo "args: $*" >&2; exec /bin/cat ;;', + ' *) exit 64 ;;', + 'esac', + '', + ].join('\n'), + { mode: 0o755 }, + ); + const script = `import { main } from ${JSON.stringify(cli)}; +process.exitCode = await main(['mcp', 'serve', ...process.argv.slice(1)], { candidates: () => [${JSON.stringify(command)}], spotlight: () => [] });`; + + for (const argv of [[], ['--read-only']]) { + const { code, out, err } = await runNode(script, argv, { input: request }); + assert.equal(code, 0, err); + assert.equal(out, request); + assert.equal(err.trim(), `args: ${['mcp', ...argv].join(' ')}`); + } +}); diff --git a/packages/cli/test/server-json.test.js b/packages/cli/test/server-json.test.js new file mode 100644 index 0000000..cf8d466 --- /dev/null +++ b/packages/cli/test/server-json.test.js @@ -0,0 +1,24 @@ +// The MCP Registry's entry, server.json at the repository's root, names this +// package at the version npm has: publish both together. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +const read = (file) => JSON.parse(fs.readFileSync(new URL(file, import.meta.url), 'utf8')); +const pkg = read('../package.json'); + +test('server.json is this package, at its version, running mcp serve', () => { + const server = read('../../../server.json'); + assert.equal(server.name, pkg.mcpName); + assert.equal(server.version, pkg.version); + assert.equal(server.packages.length, 1); + const [entry] = server.packages; + assert.equal(entry.identifier, pkg.name); + assert.equal(entry.version, pkg.version); + assert.deepEqual(entry.packageArguments.map((argument) => argument.value), ['mcp', 'serve']); + assert.ok(server.description.length <= 100, 'the registry takes 100 characters at most'); +}); + +test('the example .mcp.json runs mcp serve', () => { + assert.deepEqual(read('../../../.mcp.json').mcpServers.localhost.args, ['-y', pkg.name, 'mcp', 'serve']); +}); diff --git a/server.json b/server.json new file mode 100644 index 0000000..f5d40df --- /dev/null +++ b/server.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.marcellosso/localhost", + "title": "localhost app", + "description": "Start, stop and read logs of your local dev servers from Claude Code, Cursor and Codex.", + "websiteUrl": "https://www.getlocalhost.app/docs/mcp", + "repository": { + "url": "https://github.com/marcellosso/localhost", + "source": "github", + "subfolder": "packages/cli" + }, + "version": "0.2.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@getlocalhost/cli", + "version": "0.2.0", + "runtimeHint": "npx", + "transport": { "type": "stdio" }, + "packageArguments": [ + { "type": "positional", "value": "mcp" }, + { "type": "positional", "value": "serve" } + ] + } + ] +} diff --git a/site/src/content/changelog/0.4.0.md b/site/src/content/changelog/0.4.0.md new file mode 100644 index 0000000..ca27a6f --- /dev/null +++ b/site/src/content/changelog/0.4.0.md @@ -0,0 +1,12 @@ +--- +version: 0.4.0 +title: Agents add and edit your Projects, and connect from a terminal +date: 2026-11-03 +released: false +--- + +- **Agents add, edit and remove Projects and Services.** `localhost mcp` has six new tools, 15 in all: `create_project`, `update_project`, `delete_project`, `create_service`, `update_service` and `delete_service`. Each change is checked as the app's editors check it (Keys, ports, URLs, working directories, Dependency cycles) and shows in the open app at once, without a restart. Removing needs `confirm: true`, stops what is running first and never touches a Project's folder. Agents set plain variables only, and every Secret stays as it was. A command is stored as given, never run when it's added. See [Tools](/docs/mcp#tools). +- **`localhost mcp --read-only`** leaves those six tools out: the agent can still list, start, stop and inspect. **Settings → Agents** shows a read-only server as connected, and **Update** keeps it read-only. See [Read-only](/docs/mcp#read-only). +- **Connect your agents from a terminal.** With the app installed, `npx @getlocalhost/cli mcp install` writes what **Connect** writes for Claude Code, Cursor and Codex, and Claude Desktop's `claude_desktop_config.json` too, on macOS and Windows. `mcp uninstall` removes it, `mcp status` shows where each agent stands, `--client` picks one, `--read-only` sets up a read-only server and `--dry-run` prints the diff instead. Without the app, it changes nothing and says how to install it. See [Connect from a terminal](/docs/mcp#connect-from-a-terminal). +- **`npx -y @getlocalhost/cli mcp serve`** runs the server from any MCP client's configuration: it finds the command that comes with the app and runs `localhost mcp`, so the same `command` and `args` work in every client and on every OS. See [With npx](/docs/mcp#with-npx). +- **Install with Homebrew on macOS**: `brew install --cask marcellosso/tap/localhost` installs the app and links its `localhost` command into Homebrew's `bin`. The app still updates itself. See [Install](/docs/install#homebrew-macos). diff --git a/site/src/content/docs/mcp.md b/site/src/content/docs/mcp.md index 3e975d9..051a092 100644 --- a/site/src/content/docs/mcp.md +++ b/site/src/content/docs/mcp.md @@ -55,8 +55,9 @@ It writes what **Connect** writes, to the same files and with the same rules (on | `npx @getlocalhost/cli mcp install` | Adds the `localhost` server and the skill. | | `npx @getlocalhost/cli mcp uninstall` | Removes them, and nothing else. | | `npx @getlocalhost/cli mcp status` | Says where each agent stands, as **Settings → Agents** does. | +| `npx -y @getlocalhost/cli mcp serve` | Runs the server itself, for an agent's configuration: see [With npx](#with-npx). | | `--client ` | Only `claude-code`, `claude-desktop`, `cursor` or `codex` (`claude` for both Claude apps). Repeat it for more than one. | -| `--read-only` | Sets up `localhost mcp --read-only`, without the tools that add, edit or remove Projects and Services ([Read-only](#read-only)). Installing again without it keeps a read-only server read-only. | +| `--read-only` | For `mcp install` and `mcp serve`: sets up `localhost mcp --read-only`, without the tools that add, edit or remove Projects and Services ([Read-only](#read-only)). Installing again without it keeps a read-only server read-only. | | `--dry-run` | Prints what would change, as a diff, and changes nothing. | The package doesn't contain the app or the `localhost` command: agents run the one inside the app, [where it is](#keeping-it-current) on each OS. Without the app it changes nothing and says how to install it: Homebrew or the disk image on macOS, the installer on Windows, the packages on Linux (see [Install](/docs/install)). It needs Node 18 or later, and only edits files: no network, apart from `npx` downloading it. @@ -103,6 +104,22 @@ args = ["mcp"] Building localhost yourself? `cargo install --path core/localhost-cli` in a clone of the repository puts `localhost` in `~/.cargo/bin`. It must come from the same version as the app, which otherwise says the two speak to the core differently. +### With npx + +`npx -y @getlocalhost/cli mcp serve` is the same MCP server, run through npm, so one configuration works in every client and on every OS. + +Instead of the command's path, a client can run `npx -y @getlocalhost/cli mcp serve`, the same in every client and on every OS: + +```json +{ + "mcpServers": { + "localhost": { "command": "npx", "args": ["-y", "@getlocalhost/cli", "mcp", "serve"] } + } +} +``` + +From a terminal: `claude mcp add --scope user localhost -- npx -y @getlocalhost/cli mcp serve`, or `codex mcp add localhost -- npx -y @getlocalhost/cli mcp serve`. It finds the app's command [where it is](#keeping-it-current) and runs `localhost mcp` with the client's input and output; `mcp serve --read-only` runs `localhost mcp --read-only`. The app must be installed: without it, the server exits at once and says how to install it, on standard error, as the client's log shows. Each start goes through `npx`, so the full path above starts faster and works offline. + ## Tools Projects and Services are named by their **Key**, as `list_projects` and `list_services` show them. A name works too. diff --git a/site/src/lib/agents.ts b/site/src/lib/agents.ts index 931dd8e..c751545 100644 --- a/site/src/lib/agents.ts +++ b/site/src/lib/agents.ts @@ -32,7 +32,7 @@ export const agentsFaq: { question: string; answer: string[] }[] = [ { question: 'Which coding agents does it work with?', answer: [ - 'Any MCP client that starts a server over standard input and output. The app connects Claude Code, Cursor and Codex in one click, in Settings → Agents; from a terminal, `npx @getlocalhost/cli mcp install` connects those and Claude Desktop. Other clients take the same `command` and `args` in their own configuration file.', + 'Any MCP client that starts a server over standard input and output. The app connects Claude Code, Cursor and Codex in one click, in Settings → Agents; from a terminal, `npx @getlocalhost/cli mcp install` connects those and Claude Desktop. Other clients take the same `command` and `args` in their own configuration file, or run `npx -y @getlocalhost/cli mcp serve`, which finds the app’s command for them.', ], }, { diff --git a/site/src/pages/agents.astro b/site/src/pages/agents.astro index c642635..373dbf2 100644 --- a/site/src/pages/agents.astro +++ b/site/src/pages/agents.astro @@ -275,6 +275,9 @@ args = ["mcp"]`;
{codexConfig}
+

+ Or, on any OS: npx -y @getlocalhost/cli mcp serve as the command, which finds the app’s command for you. +

Every platform’s path, and the skill by hand: Set it up by hand.