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
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ It requires Node.js 22 or newer.
### Codex

```sh
npx --yes @archastro/intern-mcp@0.1.0 setup --host codex
npx --yes @archastro/intern-mcp@latest setup --host codex
```

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

```sh
npx --yes @archastro/intern-mcp@0.1.0 setup --host claude
npx --yes @archastro/intern-mcp@latest setup --host claude
```

Add `--verbose` to either setup command to print redacted request lifecycle
Expand All @@ -47,10 +47,12 @@ 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 setup command resolves npm's stable `latest` release and saves a launcher
that checks that channel whenever the MCP host starts. The saved launcher uses
`--prefer-online` to refresh stale package metadata instead of trusting its npx
cache; the one-time setup command does not expose that runtime policy. To pin a
reviewed build instead, run setup with `INTERN_MCP_PACKAGE` set to a complete
package spec such as `@archastro/intern-mcp@0.1.1`, or to a package tarball.

The repository is private; the package is public on npm.

Expand All @@ -59,9 +61,14 @@ that local mapping for this public package:

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

The first registry option lets npx find the setup executable. The setup
`--registry` option saves the same scoped override in the Codex or Claude
launcher so later host restarts continue resolving the public package.

Maintainers run the manual **release** workflow from `main` and choose a patch,
minor, or major bump. It verifies the package, commits the version change on a
release branch, rebase-merges the version-only PR, tags that exact merged commit
Expand Down
15 changes: 14 additions & 1 deletion scripts/harness-install-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ try {
"--host",
"codex",
"--verbose",
"--registry",
publicRegistry,
]);
if (!codexSetup.stdout.includes("Intern connected to Codex as Harness · admin")) {
throw new Error("packaged setup did not validate and configure Codex");
Expand All @@ -120,12 +122,21 @@ try {
if (
!codex.stdout.includes("intern-mcp") ||
!codex.stdout.includes("launch") ||
!codex.stdout.includes("--prefer-online") ||
!codex.stdout.includes(`--@archastro:registry=${publicRegistry}`) ||
codex.stdout.includes("harness-proof-token")
) {
throw new Error("Codex did not persist the packaged Intern MCP profile launcher");
}

const claudeSetup = await run("npx", [...setupCommand, "setup", "--host", "claude"]);
const claudeSetup = await run("npx", [
...setupCommand,
"setup",
"--host",
"claude",
"--registry",
publicRegistry,
]);
if (
!claudeSetup.stdout.includes("Intern connected to Claude Code as Harness · admin")
) {
Expand All @@ -135,6 +146,8 @@ try {
if (
!claude.stdout.includes("intern-mcp") ||
!claude.stdout.includes("launch") ||
!claude.stdout.includes("--prefer-online") ||
!claude.stdout.includes(`--@archastro:registry=${publicRegistry}`) ||
claude.stdout.includes("harness-proof-token") ||
!claude.stdout.includes("Connected")
) {
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ switch (command) {
case "setup":
try {
const options = parseSetupOptions(process.argv.slice(3));
await runSetup(config, options.host, { verbose: options.verbose });
await runSetup(config, options.host, {
verbose: options.verbose,
registry: options.registry,
});
} catch (error) {
process.stderr.write(
`Intern setup failed: ${error instanceof Error ? error.message : "request failed"}\n`,
Expand All @@ -44,7 +47,7 @@ switch (command) {
break;
default:
process.stderr.write(
"Usage: intern-mcp serve|launch|status|setup --host codex|claude [--verbose]\n",
"Usage: intern-mcp serve|launch|status|setup --host codex|claude [--verbose] [--registry URL]\n",
);
process.exitCode = 2;
}
Expand Down
46 changes: 46 additions & 0 deletions src/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe("Intern MCP setup", () => {
args: expect.arrayContaining([
"add",
"INTERN_WORKSPACE_ROOT=/tmp/Intern",
"--prefer-online",
"--package=/tmp/intern-mcp.tgz",
"launch",
]),
Expand All @@ -94,6 +95,37 @@ describe("Intern MCP setup", () => {
expect(output.join("")).not.toContain("secret-token");
});

it("saves a launcher that refreshes the latest stable package", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
await runSetup(config, "codex", {
token: "secret-token",
env,
registry: "https://registry.npmjs.org",
session: async () => session,
run: async (command, args) => {
calls.push({ command, args });
return {
status: 0,
stdout: args.includes("get")
? '{"command": "npx", "args": ["intern-mcp", "launch"]}'
: "ok",
};
},
write: () => {},
});

expect(calls[0]).toMatchObject({
command: "codex",
args: expect.arrayContaining([
"--prefer-online",
"--@archastro:registry=https://registry.npmjs.org",
"--package=@archastro/intern-mcp@latest",
"intern-mcp",
"launch",
]),
});
});

it("replaces only Claude's user-scoped Intern entry", async () => {
const calls: string[][] = [];
await runSetup(config, "claude", {
Expand Down Expand Up @@ -244,8 +276,22 @@ describe("Intern MCP setup", () => {
host: "claude",
verbose: true,
});
expect(
parseSetupOptions(["--host=codex", "--registry", "https://registry.npmjs.org/"]),
).toEqual({
host: "codex",
verbose: false,
registry: "https://registry.npmjs.org",
});
expect(() => parseSetupOptions(["--host", "cursor"])).toThrow("Usage:");
expect(() => parseSetupOptions(["--host", "codex", "--debug"])).toThrow("Usage:");
expect(() =>
parseSetupOptions([
"--host",
"codex",
"--registry=https://user:secret@example.com",
]),
).toThrow("Usage:");
});

it("reads a piped token without echoing it", async () => {
Expand Down
49 changes: 44 additions & 5 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { InternSession } from "./api.js";
import { PACKAGE_VERSION, type InternConfig } from "./config.js";

const exec = promisify(execFile);
const defaultPackage = `@archastro/intern-mcp@${PACKAGE_VERSION}`;
const defaultPackage = "@archastro/intern-mcp@latest";

export type SetupHost = "codex" | "claude";

Expand Down Expand Up @@ -40,22 +40,33 @@ interface SetupDependencies {
write?: (message: string) => void;
env?: NodeJS.ProcessEnv;
verbose?: boolean;
registry?: string;
}

class HostConfigurationCommittedError extends Error {}

export function parseSetupOptions(args: string[]): {
host: SetupHost;
verbose: boolean;
registry?: string;
} {
let host: SetupHost | undefined;
let verbose = false;
let registry: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const value = args[index];
if (value === "--verbose") {
verbose = true;
continue;
}
if (value === "--registry" || value.startsWith("--registry=")) {
if (registry !== undefined) throw new Error(setupUsage());
const registryValue = value.startsWith("--registry=")
? value.slice("--registry=".length)
: args[++index];
registry = parseRegistry(registryValue);
continue;
}
const hostValue = value.startsWith("--host=")
? value.slice("--host=".length)
: value === "--host"
Expand All @@ -71,7 +82,7 @@ export function parseSetupOptions(args: string[]): {
host = hostValue;
}
if (!host) throw new Error(setupUsage());
return { host, verbose };
return { host, verbose, ...(registry ? { registry } : {}) };
}

export async function runSetup(
Expand All @@ -82,6 +93,7 @@ export async function runSetup(
const promptToken = dependencies.promptToken ?? promptAccessToken;
const env = dependencies.env ?? process.env;
const verbose = dependencies.verbose ?? false;
const registry = dependencies.registry;
const token = (
dependencies.token ??
env.INTERN_ACCESS_TOKEN ??
Expand All @@ -101,7 +113,7 @@ export async function runSetup(
const previousToken = await snapshotFile(tokenFile);
await writeAccessToken(config, token);
try {
await configureHost(host, packageSpec, env, run);
await configureHost(host, packageSpec, registry, env, run);
} catch (error) {
if (!(error instanceof HostConfigurationCommittedError)) {
await restoreFile(tokenFile, previousToken);
Expand All @@ -124,10 +136,19 @@ export async function runSetup(
async function configureHost(
host: SetupHost,
packageSpec: string,
registry: string | undefined,
env: NodeJS.ProcessEnv,
run: (command: string, args: string[]) => Promise<CommandResult>,
): Promise<void> {
const launcher = ["npx", "--yes", `--package=${packageSpec}`, "intern-mcp", "launch"];
const launcher = [
"npx",
"--yes",
"--prefer-online",
...(registry ? [`--@archastro:registry=${registry}`] : []),
`--package=${packageSpec}`,
"intern-mcp",
"launch",
];
const environmentArgs = [
"INTERN_BASE_URL",
"INTERN_WORKSPACE_ROOT",
Expand Down Expand Up @@ -324,7 +345,25 @@ function question(lines: Interface): Promise<string> {
}

function setupUsage(): string {
return "Usage: intern-mcp setup --host codex|claude [--verbose]";
return "Usage: intern-mcp setup --host codex|claude [--verbose] [--registry URL]";
}

function parseRegistry(value: string | undefined): string {
try {
const registry = new URL(value ?? "");
if (
registry.protocol !== "https:" ||
registry.username ||
registry.password ||
registry.search ||
registry.hash
) {
throw new Error("unsafe registry");
}
return registry.toString().replace(/\/$/, "");
} catch {
throw new Error(setupUsage());
}
}

export async function readStoredAccessToken(config: InternConfig): Promise<string> {
Expand Down
Loading