Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 58 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,12 @@ Local stdio MCP server for working on Intern-hosted sites through guarded Git ch

Intern MCP is packaged for distribution as the public
`@archastro/intern-mcp` npm package.
It requires Node.js 22 or newer. The explicit scoped registry override keeps
installation working on machines that route `@archastro` elsewhere.
It requires Node.js 22 or newer.

### Codex

```sh
codex mcp add intern -- \
npx --yes --@archastro:registry=https://registry.npmjs.org \
@archastro/intern-mcp@0.1.0 serve
codex mcp get intern
npx --yes @archastro/intern-mcp@0.1.0 setup --host codex
```

Restart Codex after adding the server. Codex stores the stdio command in its
Expand All @@ -24,34 +20,43 @@ user configuration and starts the package when a session needs the server.
### Claude Code

```sh
claude mcp add --transport stdio --scope user intern -- \
npx --yes --@archastro:registry=https://registry.npmjs.org \
@archastro/intern-mcp@0.1.0 serve
claude mcp get intern
npx --yes @archastro/intern-mcp@0.1.0 setup --host claude
```

The user scope makes Intern available in every Claude Code project. Use
`--scope local` instead if it should only be available in the current project.
Run `/mcp` inside Claude Code to inspect the connection.
The installer uses Claude Code's user scope, so Intern is available in every
project. Run `/mcp` inside Claude Code to inspect the connection.

Create a profile access token at <https://tryintern.dev/connect>, copy it, then
run the command for your host. The installer validates the token, configures the
host through its native CLI, verifies the saved registration, and prints the
Intern organization and role. Paste the token at the hidden terminal prompt so
it never enters shell history. Intern displays it only once. The host stores it
through the `intern-mcp launch` profile command. The bearer itself lives in
`~/.config/intern/access-token` with mode `0600`; it is never placed in child
process arguments or host configuration.

The two harnesses launch the same local stdio executable. Intern MCP does not
run an OAuth flow or accept a token through a tool call. To rotate access, run
setup with a new token, restart the host, and revoke the old token on the
Connect page. Revocation blocks new API calls and SSH certificates immediately;
a Git certificate already issued can remain valid until its five-minute expiry.

Upgrades are explicit: rerun the corresponding setup command with the reviewed
version replacing `0.1.0`. The installer updates only the user-level `intern`
registration. The host never executes a newly published package version merely
because it restarted.

The two harnesses launch the same local stdio executable. Authentication still
uses `intern_login` and the TryIntern device page; no OAuth secret or service
credential is placed in harness configuration.
The repository is private and the npm package has not been published yet. The
commands above become available after the first package release.

Upgrades are explicit: remove the existing registration and add it again with
the reviewed version replacing `0.1.0`:
If a developer machine maps the `@archastro` scope to another registry, override
that local mapping for this public package:

```sh
codex mcp remove intern
claude mcp remove intern
npx --yes --@archastro:registry=https://registry.npmjs.org \
@archastro/intern-mcp@0.1.0 setup --host codex
```

Then rerun the corresponding command above. The harness never executes a newly
published package version merely because it restarted.

The repository is private and the npm package has not been published yet. The
commands above become available after the first package release.

Maintainers run the manual **release** workflow to publish the version in
`package.json`. The first release uses a short-lived `NPM_TOKEN` repository
secret in the protected `npm-release` environment because npm cannot attach a
Expand All @@ -63,25 +68,25 @@ authenticate with GitHub OIDC.

## Configure the server

Production public values are built in:

- TryIntern origin: `https://tryintern.dev`
- ArchAstro API: `https://platform.archastro.ai`
- OAuth client: `cc_vuMmqN4VbAKy8zsWRYorUg`, with only `profile` scope
- TryIntern's public ArchAstro publishable key

The OAuth client secret is not used by this device-flow public client. Do not put it in MCP or TryIntern configuration.
The production TryIntern origin is built in. `INTERN_ACCESS_TOKEN` is required
for authenticated API calls and should be a profile-scoped token created on the
Connect page. `intern-mcp serve` reads it directly from the environment for
manual and CI configurations. `intern-mcp launch` reads the mode-0600 profile
written by setup, then supplies the same token contract internally.

These optional environment values override the defaults for local testing or custom workspace setup:

- `INTERN_BASE_URL` — Intern frontend origin.
- `ARCHASTRO_API_URL` — ArchAstro API origin.
- `ARCHASTRO_PUBLISHABLE_KEY` — Intern app publishable key.
- `INTERN_OAUTH_CLIENT_ID` — alternate device-flow client registered with only `profile` scope.
- `INTERN_BASE_URL` — Intern frontend/API origin.
- `INTERN_WORKSPACE_ROOT` — parent directory for `<org>/<site>` checkouts.
- `INTERN_CONFIG_ROOT` — directory for the access-token profile and SSH material.
- `INTERN_GIT_SSH_COMMAND` — optional per-process SSH command for development or custom SSH setup.
- `INTERN_IAP_ID_TOKEN` (or `IAP_ID_TOKEN`) — Google ID token for the IAP-protected production frontend. It is sent through `Proxy-Authorization` while the ArchAstro bearer remains in `Authorization`.

When the base URL, workspace root, config root, or Git SSH command is present
while `setup` runs, the installer saves that nonsecret override in the selected
host's MCP environment. IAP tokens remain manual/CI-only credentials and are
not copied into host configuration by setup.

For development from this repository, install and build with:

```sh
Expand All @@ -98,12 +103,15 @@ node /absolute/path/to/intern-mcp/dist/index.js serve
For Codex, the checkout-based equivalent is:

```sh
codex mcp add intern \
--env INTERN_WORKSPACE_ROOT=/absolute/path/to/Intern \
-- node /absolute/path/to/intern-mcp/dist/index.js serve
INTERN_MCP_PACKAGE=/absolute/path/to/intern-mcp \
INTERN_WORKSPACE_ROOT=/absolute/path/to/Intern \
node /absolute/path/to/intern-mcp/dist/index.js setup --host codex
```

Use `intern_login`, approve the browser prompt, and call `intern_complete_login`. Then use `intern_prepare_site` and edit the returned absolute path with the coding host's normal filesystem tools. The prepare result includes validation against the authenticated Intern runtime contract.
Restart Codex after adding the server. Call `intern_auth_status`, then use
`intern_prepare_site` and edit the returned absolute path with the coding
host's normal filesystem tools. The prepare result includes validation against
the authenticated Intern runtime contract.

## Run against a local Firstlanding stack

Expand Down Expand Up @@ -131,27 +139,24 @@ one atomic Aster port allocation. The launcher uses
TryIntern, or Git gateway SSH ports. Both Intern topologies expose the same API
and Git contract to the MCP; only the control plane's site provider changes.
For local Git, the launcher routes `git.intern.dev` SSH traffic to the reported
loopback gateway port. The launcher loads the local publishable key and profile-only OAuth client from
`.env.intern.local`, builds the MCP, and keeps its local credentials and site
checkouts under this repository's ignored `tmp/` directory. `INTERN_CONFIG_ROOT`
and `INTERN_WORKSPACE_ROOT` still override those paths. Test harnesses can set
`INTERN_LOCAL_ENV_FILE` to load a different local public-client configuration.
If the worktree's ports changed, the launcher prints the exact command that
reconciles the local OAuth client's device verification URI before starting.
loopback gateway port. The launcher requires `INTERN_ACCESS_TOKEN`, builds the
MCP, and keeps its SSH material and site checkouts under this repository's
ignored `tmp/` directory. Create the token from the local Intern Connect page,
export it in the terminal that starts the launcher, and keep it out of shell
scripts and source control. `INTERN_CONFIG_ROOT` and `INTERN_WORKSPACE_ROOT`
still override the default paths.

The same launcher exposes the standalone commands for manual checks:

```sh
INTERN_PLATFORM_WORKSPACE=../firstlanding-wt2 scripts/run-local.sh login
INTERN_PLATFORM_WORKSPACE=../firstlanding-wt2 scripts/run-local.sh status
INTERN_PLATFORM_WORKSPACE=../firstlanding-wt2 scripts/run-local.sh logout
```

After edits, call `intern_test_site` before committing. It validates tracked and untracked working-tree files, excludes ignored files, and returns an ephemeral `http://127.0.0.1:<port>` preview URL. The URL serves a temporary snapshot, so call the tool again after further edits. `intern_stop_test` stops it without needing the backend or a current login. Logout and stdio shutdown also stop every preview and remove its snapshot.
After edits, call `intern_test_site` before committing. It validates tracked and untracked working-tree files, excludes ignored files, and returns an ephemeral `http://127.0.0.1:<port>` preview URL. The URL serves a temporary snapshot, so call the tool again after further edits. `intern_stop_test` stops it without needing the backend or a current token. Stdio shutdown also stops every preview and removes its snapshot.

Once the local result is correct, commit the change and call `intern_validate_site`. It checks the exact committed tree: required and protected runtime files, dependencies the backend does not install, JavaScript syntax, production-style startup, and an HTTP probe. `intern_publish_site` reruns the same commit validation and refuses invalid or dirty worktrees. The MCP never stages or commits files.

For each SSH clone or push, Intern MCP creates or reuses one local Ed25519 key and sends only its public half to Intern. Intern returns a five-minute user certificate plus the pinned `git.tryintern.dev` host key. MCP supplies those files only to that Git process; it never edits global Git config, `~/.ssh/config`, or the user's `known_hosts`. Logout removes the current certificate but retains the private key for future short-lived certificates.
For each SSH clone or push, Intern MCP creates or reuses one local Ed25519 key and sends only its public half to Intern. Intern returns a five-minute user certificate plus the pinned `git.tryintern.dev` host key. MCP supplies those files only to that Git process; it never edits global Git config, `~/.ssh/config`, or the user's `known_hosts`. The MCP replaces the current certificate as needed and retains the private key for future short-lived certificates.

The current runtime contract is deliberately narrow: Intern runs its protected `server.mjs` through `run-site.sh`, supplies `PORT`, and does not install package dependencies. A model can edit HTML, CSS, browser JavaScript, and assets. Runtime launcher changes are rejected because the current Git publish path does not restart the site process.

Expand Down
13 changes: 1 addition & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@
"start": "node dist/index.js serve"
},
"dependencies": {
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/server": "2.0.0",
"zod": "4.4.3"
},
"devDependencies": {
"@eslint/js": "9.39.2",
"@modelcontextprotocol/client": "2.0.0",
"@types/node": "^22",
"eslint": "9.39.2",
"globals": "16.5.0",
Expand Down
49 changes: 49 additions & 0 deletions scripts/access-token-e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import path from "node:path";

const packageRoot = path.resolve(import.meta.dirname, "..");
const expectUnauthorized = process.argv.includes("--expect-unauthorized");
if (!process.env.INTERN_ACCESS_TOKEN) {
throw new Error("INTERN_ACCESS_TOKEN is required for the live MCP proof");
}

const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(packageRoot, "dist/index.js"), "serve"],
env: process.env,
stderr: "pipe",
});
const client = new Client({ name: "intern-access-token-proof", version: "1.0.0" });

await client.connect(transport);
try {
const status = await client.callTool({ name: "intern_auth_status", arguments: {} });
const authorized = status.structuredContent?.authorized;
const org = status.structuredContent?.session?.org?.slug;
if (expectUnauthorized) {
if (authorized !== false) {
throw new Error(
`Intern MCP still accepted the revoked token: ${JSON.stringify(status.structuredContent)}`,
);
}
process.stdout.write(
"PASS Intern MCP rejected the revoked access token over stdio\n",
);
process.exitCode = 0;
} else {
if (authorized !== true || typeof org !== "string" || !org) {
throw new Error(
`Intern MCP did not authorize the profile token: ${JSON.stringify(status.structuredContent)}`,
);
}

const listed = await client.callTool({ name: "intern_list_sites", arguments: {} });
if (!Array.isArray(listed.structuredContent?.sites)) {
throw new Error("Intern MCP did not return a site list");
}
process.stdout.write(`PASS Intern MCP access token over stdio for ${org}\n`);
}
} finally {
await client.close();
}
Loading
Loading