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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ permissions:
jobs:
publish:
name: Verify and publish npm package
if: github.ref == 'refs/heads/main'
environment: npm-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ installation working on machines that route `@archastro` elsewhere.
```sh
codex mcp add intern -- \
npx --yes --@archastro:registry=https://registry.npmjs.org \
@archastro/intern-mcp@latest serve
@archastro/intern-mcp@0.1.0 serve
codex mcp get intern
```

Expand All @@ -26,7 +26,7 @@ user configuration and starts the package when a session needs the server.
```sh
claude mcp add --transport stdio --scope user intern -- \
npx --yes --@archastro:registry=https://registry.npmjs.org \
@archastro/intern-mcp@latest serve
@archastro/intern-mcp@0.1.0 serve
claude mcp get intern
```

Expand All @@ -38,15 +38,28 @@ 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.

Upgrades are explicit: remove the existing registration and add it again with
the reviewed version replacing `0.1.0`:

```sh
codex mcp remove intern
claude mcp remove intern
```

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 because npm cannot attach a trusted publisher to a package that does
not exist. After `0.1.0`, configure `ArchAstro/intern-mcp` and `release.yml` as
the npm trusted publisher, remove the secret, and later runs authenticate with
GitHub OIDC.
secret in the protected `npm-release` environment because npm cannot attach a
trusted publisher to a package that does not exist. That environment accepts
only protected branches; `main` itself requires CI and approving review. After
`0.1.0`, configure `ArchAstro/intern-mcp`, `release.yml`, and environment
`npm-release` as the npm trusted publisher, remove the secret, and later runs
authenticate with GitHub OIDC.

## Configure the server

Expand Down
6 changes: 3 additions & 3 deletions scripts/run-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ ports=$(printf '%s' "$ports_json" | node --input-type=module -e '
// Use the Aster worktree allocation instead of assuming default local ports.
const platform = instance.ports?.platform;
const frontend = servicePort("intern-fe");
// Aster 0.11 reports selected service ports. The SSH listener is an
// auxiliary port derived from the same worktree Platform allocation.
const gatewaySSH = instance.ports?.["intern-gateway-ssh"] ?? 2222 + Math.max(platform - 4000, 0);
// Require the auxiliary port reported by Aster. Deriving it locally can disagree
// with the collision-free Aster allocation and target the wrong SSH listener.
const gatewaySSH = instance.ports?.["intern-gateway-ssh"];
const validPort = (port) => Number.isInteger(port) && port >= 1 && port <= 65535;
if (!validPort(platform) || !validPort(frontend) || !validPort(gatewaySSH)) {
console.error("The active Intern stack did not report its platform, intern-fe, and intern-gateway-ssh ports.");
Expand Down
67 changes: 67 additions & 0 deletions src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { assertHttpUrl, AuthClient } from "./auth.js";

const config = {
internBaseURL: "https://tryintern.dev",
archAstroBaseURL: "https://platform.archastro.ai",
publishableKey: "pk_test",
oauthClientID: "cc_test",
workspaceRoot: "/tmp/intern-auth-test/sites",
configRoot: "/tmp/intern-auth-test/config",
};

describe("assertHttpUrl", () => {
it("accepts HTTPS and loopback HTTP device pages", () => {
expect(
assertHttpUrl("https://tryintern.dev/device?code=ABCD", "verification URL"),
).toBe("https://tryintern.dev/device?code=ABCD");
expect(assertHttpUrl("http://127.0.0.1:3100/device", "verification URL")).toBe(
"http://127.0.0.1:3100/device",
);
});

it("rejects non-HTTP schemes, credentials, and non-loopback HTTP", () => {
expect(() => assertHttpUrl("javascript:alert(1)", "verification URL")).toThrow(
"non-HTTP",
);
expect(() => assertHttpUrl("file:///etc/passwd", "verification URL")).toThrow(
"non-HTTP",
);
expect(() =>
assertHttpUrl("https://user:pass@tryintern.dev/device", "verification URL"),
).toThrow("embedded credentials");
expect(() =>
assertHttpUrl("http://evil.example/device", "verification URL"),
).toThrow("non-HTTPS");
});
});

describe("AuthClient.startLogin", () => {
it("refuses a device-approval URL that is not http(s)", async () => {
const auth = new AuthClient(config, async () =>
Response.json({
device_code: "dc",
user_code: "ABCD-EFGH",
verification_uri: "javascript:alert(1)",
verification_uri_complete: "javascript:alert(1)",
expires_in: 600,
interval: 1,
}),
);
await expect(auth.startLogin(false)).rejects.toThrow("non-HTTP");
});

it("refuses verification URLs on different origins", async () => {
const auth = new AuthClient(config, async () =>
Response.json({
device_code: "dc",
user_code: "ABCD-EFGH",
verification_uri: "https://tryintern.dev/device",
verification_uri_complete: "https://evil.example/device?code=ABCD-EFGH",
expires_in: 600,
interval: 1,
}),
);
await expect(auth.startLogin(false)).rejects.toThrow("different origins");
});
});
47 changes: 39 additions & 8 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,22 @@ export class AuthClient {
const body = await json(response);
if (!response.ok)
throw new Error(`authorization start failed: ${errorMessage(body)}`);
const verificationURI = assertHttpUrl(
requiredString(body, "verification_uri"),
"verification URL",
);
const verificationURIComplete = assertHttpUrl(
requiredString(body, "verification_uri_complete"),
"verification URL",
);
if (new URL(verificationURI).origin !== new URL(verificationURIComplete).origin) {
throw new Error("Intern returned verification URLs on different origins");
}
const pending: PendingAuthorization = {
deviceCode: requiredString(body, "device_code"),
userCode: requiredString(body, "user_code"),
verificationURI: requiredString(body, "verification_uri"),
verificationURIComplete: requiredString(body, "verification_uri_complete"),
verificationURI,
verificationURIComplete,
expiresAt: Date.now() + requiredNumber(body, "expires_in") * 1000,
intervalSeconds: Math.max(requiredNumber(body, "interval"), 1),
};
Expand Down Expand Up @@ -240,15 +251,35 @@ function errorMessage(value: Record<string, unknown>): string {
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export function assertHttpUrl(value: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`Intern returned an invalid ${label}`);
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
throw new Error(`Intern returned a non-HTTP ${label}`);
if (parsed.username || parsed.password)
throw new Error(`Intern returned a ${label} with embedded credentials`);
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname))
throw new Error(`Intern returned a non-HTTPS ${label}`);
return parsed.href;
}

function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}

function openBrowser(url: string): void {
const command =
const href = assertHttpUrl(url, "verification URL");
const child =
process.platform === "darwin"
? "open"
? spawn("open", [href], { detached: true, stdio: "ignore" })
: process.platform === "win32"
? "cmd"
: "xdg-open";
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
const child = spawn(command, args, { detached: true, stdio: "ignore" });
? spawn("explorer.exe", [href], { detached: true, stdio: "ignore" })
: spawn("xdg-open", [href], { detached: true, stdio: "ignore" });
child.on("error", () => {});
child.unref();
}
10 changes: 10 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
DEFAULT_ARCHASTRO_API_URL,
DEFAULT_ARCHASTRO_PUBLISHABLE_KEY,
DEFAULT_INTERN_BASE_URL,
DEFAULT_INTERN_OAUTH_CLIENT_ID,
loadConfig,
PACKAGE_VERSION,
} from "./config.js";

describe("loadConfig", () => {
it("uses the published package version for MCP server identity", () => {
const packed = JSON.parse(
readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"),
) as { version: string };
expect(PACKAGE_VERSION).toBe(packed.version);
});

it("uses TryIntern production public values by default", () => {
const config = loadConfig({ HOME: "/tmp/intern-config-test" });

Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";

export const PACKAGE_VERSION = (
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
version: string;
}
).version;

export const DEFAULT_INTERN_BASE_URL = "https://tryintern.dev";
export const DEFAULT_ARCHASTRO_API_URL = "https://platform.archastro.ai";
export const DEFAULT_ARCHASTRO_PUBLISHABLE_KEY =
Expand Down
7 changes: 6 additions & 1 deletion src/launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ process.stdout.write(JSON.stringify({
{ name: "intern-ctl-devbox", port_name: "intern-ctl", port: 5102 },
{ name: "intern-fe", port_name: "intern-fe", port: ${frontendPort} }
],
ports: { platform: ${platformPort}, "intern-fe": ${frontendPort}, "intern-ctl": 5102 }
ports: {
platform: ${platformPort},
"intern-fe": ${frontendPort},
"intern-ctl": 5102,
"intern-gateway-ssh": 2224
}
}]
}));
`,
Expand Down
79 changes: 78 additions & 1 deletion src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,94 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { afterEach, expect, test } from "vitest";
import type { SiteRuntimeContract } from "./api.js";
import { PACKAGE_VERSION } from "./config.js";
import { buildServer } from "./server.js";

const exec = promisify(execFile);
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});

test("advertises MCP titles, instructions, field descriptions, and workflow prompts", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = buildServer(
{
hasCredentials: async () => false,
} as never,
{
listSites: async () => [{ slug: "docs" }, { slug: "blog" }],
} as never,
{} as never,
);
const client = new Client({ name: "intern-contract", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
expect(client.getServerVersion()).toMatchObject({
name: "intern",
version: PACKAGE_VERSION,
});
expect(client.getInstructions()).toMatch(/intern_prepare_site/);
expect(client.getInstructions()).toMatch(/never stages or commits/i);

const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toEqual(
expect.arrayContaining([
"intern_auth_status",
"intern_login",
"intern_complete_login",
"intern_logout",
"intern_list_sites",
"intern_prepare_site",
"intern_site_status",
"intern_validate_site",
"intern_test_site",
"intern_stop_test",
"intern_publish_site",
]),
);
for (const tool of tools.tools) expect(tool.title, tool.name).toBeTruthy();
const prepare = tools.tools.find((tool) => tool.name === "intern_prepare_site");
expect(prepare?.inputSchema).toMatchObject({
properties: {
site: { description: expect.stringMatching(/slug/i) },
createIfMissing: { description: expect.stringMatching(/create/i) },
},
});
expect(prepare?.annotations).toMatchObject({
readOnlyHint: false,
idempotentHint: true,
});

const prompts = await client.listPrompts();
expect(prompts.prompts.map((prompt) => prompt.name)).toEqual(
expect.arrayContaining(["intern_sign_in", "intern_work_on_site"]),
);
const workflow = await client.getPrompt({
name: "intern_work_on_site",
arguments: { site: "docs" },
});
expect(workflow.messages[0]?.content).toMatchObject({
type: "text",
text: expect.stringContaining('intern_prepare_site with site "docs"'),
});

const resources = await client.listResources();
expect(
resources.resources.find((resource) => resource.uri === "intern://session")
?.description,
).toMatch(/does not include credentials/i);
} finally {
await client.close();
await server.close();
}
});

async function previewTemporaryDirectories(): Promise<Set<string>> {
const temporaryRoot = await fs.realpath(os.tmpdir());
return new Set(
Expand Down
Loading
Loading