From 0b4295d9d5632cc7bbd4902261cd0ffee21baee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Wed, 30 Sep 2026 23:21:46 +0700 Subject: [PATCH 1/2] feat(cli): install agent MCP connections and enrollment skills --- docs/product/agent-install.md | 31 ++ docs/reference/error-reference.md | 17 + packages/cli/e2e/agent-install.e2e.ts | 139 ++++++++ packages/cli/package.json | 6 +- packages/cli/src/cli.ts | 13 + packages/cli/src/commands/agent/install.ts | 296 ++++++++++++++++++ packages/cli/src/markdown.d.ts | 4 + packages/cli/tests/e2e-coverage.test.ts | 2 + packages/cli/tests/mount-coverage.test.ts | 3 + packages/cli/tsdown.config.ts | 12 + packages/prisma/package.json | 4 +- packages/prisma/tsconfig.json | 2 +- packages/prisma/tsdown.config.ts | 12 + pnpm-lock.yaml | 18 ++ skills/prisma-agent-enrollment/SKILL.md | 85 +++++ skills/prisma-platform-core-concepts/SKILL.md | 9 + 16 files changed, 649 insertions(+), 4 deletions(-) create mode 100644 docs/product/agent-install.md create mode 100644 packages/cli/e2e/agent-install.e2e.ts create mode 100644 packages/cli/src/commands/agent/install.ts create mode 100644 packages/cli/src/markdown.d.ts create mode 100644 skills/prisma-agent-enrollment/SKILL.md diff --git a/docs/product/agent-install.md b/docs/product/agent-install.md new file mode 100644 index 00000000..c4028045 --- /dev/null +++ b/docs/product/agent-install.md @@ -0,0 +1,31 @@ +# Agent install + +`prisma agent install` adds the Prisma remote MCP connection and its enrollment +skill to the current project. It does not sign in, create an API token, or use +the CLI's human credentials. OAuth sign-in happens in the MCP client. + +The default configures Codex, Claude Code, and Cursor. `--client codex`, +`--client claude`, or `--client cursor` installs only that client. Codex uses +`.codex/config.toml` and `.agents/skills`; Claude Code uses `.mcp.json` and +`.claude/skills`; Cursor uses `.cursor/mcp.json` and `.cursor/skills`. + +`--url` selects another HTTPS MCP endpoint, for example a preview server. +The production default is `https://mcp.prisma.io/mcp`. No authorization headers +are written. The client handles credential storage and refresh. + +The command preserves other MCP servers, comments, and unrelated configuration. +An existing Prisma connection with another URL, headers, or different transport +is refused. Invalid configuration, a symlink target, and an enrollment skill +not owned by Prisma are also refused. It checks every target before writing any +file. Repeating the command with the same endpoint is safe. + +Human output lists the configured clients and the next sign-in step. JSON output +returns the endpoint, clients, and changed file paths. Partial filesystem writes +can occur if a write fails; the error says to correct the filesystem problem and +rerun the command. The built-binary filesystem test proves the installation +without requiring a platform credential. + +New MCP connections enroll a persistent agent with access to the sponsor's +default workspace. The sponsor can change the workspace list in Console. The +skill prefers native MCP Events for approvals when the client supports them, +and otherwise uses a bounded status check every five seconds. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index efbf270c..76b7f4ba 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -565,3 +565,20 @@ A warn diagnostic from the skills sync (`skills sync`, and the sync step of `ini ### SKILLS.VERSION_CONFLICT A warn diagnostic from the skills sync (`skills sync`, and the sync step of `init`): workspace members install different versions of the same skill-bearing Prisma package, so the skills for the highest version were installed and the members pinning a lower version get a skill describing a version they did not install. The nextAction is to pin one version of the package across the workspace. Meta: none. + + +### CLI.AGENT_INSTALL_CONFIG + +The MCP configuration could not be parsed. Fix the named file and rerun +`prisma agent install`. + +### CLI.AGENT_INSTALL_CONFLICT + +An existing Prisma MCP connection differs from the requested endpoint, a target +is a symbolic link, or the enrollment skill belongs to the user. Review or move +the named file before rerunning the installer. Other clients remain unchanged. + +### CLI.AGENT_INSTALL_IO + +The installer could not read or write a project file. Check file permissions +and rerun `prisma agent install`. diff --git a/packages/cli/e2e/agent-install.e2e.ts b/packages/cli/e2e/agent-install.e2e.ts new file mode 100644 index 00000000..fe63a92a --- /dev/null +++ b/packages/cli/e2e/agent-install.e2e.ts @@ -0,0 +1,139 @@ +// biome-ignore-all lint/performance/noAwaitInLoops: each assertion checks an installed client directory. +import { execFile } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { parse as parseToml } from "smol-toml"; +import { afterEach, describe, expect, it } from "vitest"; +import { CLI_BINARY } from "./harness"; + +const run = promisify(execFile); +const roots: string[] = []; +async function project() { + const root = await mkdtemp(path.join(os.tmpdir(), "prisma-agent-install-")); + roots.push(root); + return root; +} +async function install(cwd: string, args: string[] = []) { + return run( + process.execPath, + [CLI_BINARY, "agent", "install", "--json", ...args], + { + cwd, + env: { + ...process.env, + HOME: cwd, + PRISMA_DISABLE_TELEMETRY: "1", + DO_NOT_TRACK: "1", + }, + }, + ); +} +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("agent install through the built binary", () => { + it("installs all clients without a Prisma package or credential and is idempotent", async () => { + const cwd = await project(); + await install(cwd); + const codex = parseToml( + await readFile(path.join(cwd, ".codex/config.toml"), "utf8"), + ); + expect(codex).toEqual({ + mcp_servers: { prisma: { url: "https://mcp.prisma.io/mcp" } }, + }); + expect( + JSON.parse(await readFile(path.join(cwd, ".mcp.json"), "utf8")), + ).toEqual({ + mcpServers: { + prisma: { type: "http", url: "https://mcp.prisma.io/mcp" }, + }, + }); + for (const dir of [".agents", ".claude", ".cursor"]) { + expect( + await readFile( + path.join(cwd, dir, "skills/prisma-agent-enrollment/SKILL.md"), + "utf8", + ), + ).toContain("prisma.approval.resolved"); + } + const before = await readFile(path.join(cwd, ".codex/config.toml"), "utf8"); + await install(cwd); + expect(await readFile(path.join(cwd, ".codex/config.toml"), "utf8")).toBe( + before, + ); + }); + it("preserves other servers and comments when installing one client", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, ".codex")); + await writeFile( + path.join(cwd, ".codex/config.toml"), + '# Project settings\n[mcp_servers.docs]\nurl = "https://example.com/mcp"\n', + ); + await install(cwd, ["--client", "codex"]); + expect( + await readFile(path.join(cwd, ".codex/config.toml"), "utf8"), + ).toContain("# Project settings"); + expect( + parseToml(await readFile(path.join(cwd, ".codex/config.toml"), "utf8")), + ).toMatchObject({ + mcp_servers: { + docs: { url: "https://example.com/mcp" }, + prisma: { url: "https://mcp.prisma.io/mcp" }, + }, + }); + await expect(readFile(path.join(cwd, ".mcp.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("refuses conflicting credentials before writing any client files", async () => { + const cwd = await project(); + const content = JSON.stringify({ + mcpServers: { + prisma: { + url: "https://mcp.prisma.io/mcp", + headers: { Authorization: "test-placeholder" }, + }, + }, + }); + await writeFile(path.join(cwd, ".mcp.json"), content); + await expect(install(cwd)).rejects.toMatchObject({ code: 2 }); + expect(await readFile(path.join(cwd, ".mcp.json"), "utf8")).toBe(content); + await expect( + readFile(path.join(cwd, ".codex/config.toml")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("refuses user-owned skills and linked directories", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, ".agents/skills/prisma-agent-enrollment"), { + recursive: true, + }); + await writeFile( + path.join(cwd, ".agents/skills/prisma-agent-enrollment/SKILL.md"), + "My instructions", + ); + await expect(install(cwd, ["--client", "codex"])).rejects.toMatchObject({ + code: 2, + }); + await rm(path.join(cwd, ".agents"), { recursive: true }); + const other = await project(); + await symlink(other, path.join(cwd, ".codex")); + await expect(install(cwd, ["--client", "codex"])).rejects.toMatchObject({ + code: 2, + }); + await expect( + readFile(path.join(other, "config.toml")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 756db22a..db00a389 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,7 +59,9 @@ "cross-spawn": "^7.0.6", "dotenv": "^17.4.2", "execa": "^9.6.1", - "open": "^11.0.0" + "jsonc-parser": "^3.3.1", + "open": "^11.0.0", + "smol-toml": "^1.9.0" }, "devDependencies": { "@prisma/composer": "0.25.0", @@ -67,8 +69,8 @@ "@repo/cli-conformance": "workspace:8.0.0-rc.19", "@repo/cli-telemetry": "workspace:8.0.0-rc.19", "@repo/tsconfig": "workspace:8.0.0-rc.19", - "@types/node": "^22.19.19", "@types/cross-spawn": "^6.0.6", + "@types/node": "^22.19.19", "tsdown": "^0.21.10", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7afbb9cc..04708f94 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,7 @@ import { import { createComposerFamily } from "@prisma/composer-cli/family"; import { ormCommandFamily as ormToolchainFamily } from "@prisma/orm-toolchain/cli"; import { CLI_DOCS_URL, CLI_NAME, DOCS_ERRORS_BASE_URL } from "./cli-name"; +import { agentInstallCommand } from "./commands/agent/install"; import { authLoginCommand } from "./commands/auth/login"; import { authLogoutCommand } from "./commands/auth/logout"; import { authWhoamiCommand } from "./commands/auth/whoami"; @@ -161,6 +162,11 @@ export { skillsCommandFamily }; * text that belongs to them; both halves are spread in below. */ const telemetry = telemetryCommandGroup({ docsUrl: CLI_DOCS_URL }); +export const agentCommandFamily: CommandFamily = defineCommandFamily({ + docsBaseUrl: DOCS_ERRORS_BASE_URL, + commands: { install: agentInstallCommand }, +}); + export const cliGroups: Readonly< Record< string, @@ -346,6 +352,11 @@ export const cliGroups: Readonly< "A ref is a named pointer to a contract, letting commands target a contract by a stable name. Set, list, and delete refs here.", }, orm: { brief: "Initialize a Prisma ORM project" }, + agent: { + brief: "Connect an AI agent to Prisma", + description: + "Install the Prisma MCP connection and enrollment skill in this project. The agent client handles sign-in and secure credential storage.", + }, skills: { brief: "Manage Prisma skills for AI coding agents. Sync and list the instruction files", @@ -444,6 +455,7 @@ export const mountedCommands: Readonly> = { "migration ref set": ormCommandFamily.commands["migration ref set"], // Local utilities: no owning package, no config section, no API. init: initCommand, + "agent install": agentInstallCommand, "skills sync": skillsCommandFamily.commands.sync, "skills list": skillsCommandFamily.commands.list, feedback: feedbackCommand, @@ -460,6 +472,7 @@ export function buildCli(): Cli { composerCommandFamily, ormCommandFamily, skillsCommandFamily, + agentCommandFamily, ], groups: cliGroups, commands: mountedCommands, diff --git a/packages/cli/src/commands/agent/install.ts b/packages/cli/src/commands/agent/install.ts new file mode 100644 index 00000000..cc215e01 --- /dev/null +++ b/packages/cli/src/commands/agent/install.ts @@ -0,0 +1,296 @@ +// biome-ignore-all lint/performance/noAwaitInLoops: parent checks and file writes must finish in order. +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { defineCommand, flag } from "@prisma/cli-engine"; +import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; +import { Result } from "better-result"; +import { + applyEdits, + modify, + type ParseError, + parse as parseJson, +} from "jsonc-parser"; +import { parse as parseToml } from "smol-toml"; +import { parseSkillStamp } from "../../lib/skills/frontmatter"; + +const CLIENTS = { + codex: { config: ".codex/config.toml", skills: ".agents/skills" }, + claude: { config: ".mcp.json", skills: ".claude/skills" }, + cursor: { config: ".cursor/mcp.json", skills: ".cursor/skills" }, +} as const; + +/** Reads only regular project files, including their parent directories. */ +async function readInstallTarget(cwd: string, relative: string) { + return Result.tryPromise({ + try: async () => { + const parts = relative.split(`/`); + for (let i = 1; i <= parts.length; i++) { + const target = path.join(cwd, ...parts.slice(0, i)); + const info = await lstat(target).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }, + ); + if (info?.isSymbolicLink()) + throw new CliStructuredError( + "CLI.AGENT_INSTALL_CONFLICT", + `${relative} contains a symbolic link. Choose a project-local file before installing.`, + ); + if (!info) return null; + } + return await readFile(path.join(cwd, relative), "utf8"); + }, + catch: (error) => + error instanceof CliStructuredError + ? error + : new CliStructuredError( + "CLI.AGENT_INSTALL_IO", + `Could not read ${relative}. Check its permissions and rerun agent install.`, + ), + }); +} + +function parseMcpConfiguration( + client: keyof typeof CLIENTS, + original: string | null, + file: string, +) { + return Result.try({ + try: () => { + const errors: ParseError[] = []; + let document: unknown = {}; + if (original) + document = + client === "codex" + ? parseToml(original) + : parseJson(original, errors); + if ( + errors.length || + !document || + typeof document !== `object` || + Array.isArray(document) + ) + throw new TypeError(`Invalid MCP configuration`); + const key = client === `codex` ? `mcp_servers` : `mcpServers`; + const servers = (document as Record)[key]; + if (servers === undefined) return undefined; + if (!servers || typeof servers !== `object` || Array.isArray(servers)) + throw new TypeError(`Invalid MCP servers`); + return (servers as Record).prisma; + }, + catch: () => + new CliStructuredError( + `CLI.AGENT_INSTALL_CONFIG`, + `Could not parse ${file}. Fix the configuration and rerun agent install.`, + ), + }); +} + +function matchesMcpConnection( + existing: unknown, + expected: Record, +): boolean { + if (!existing || typeof existing !== "object" || Array.isArray(existing)) + return false; + const server = existing as Record; + return ( + Object.keys(server).every((field) => field in expected) && + Object.entries(expected).every(([field, value]) => server[field] === value) + ); +} + +async function prepareMcpConfiguration( + cwd: string, + client: keyof typeof CLIENTS, + url: string, +) { + const changes: Array<{ file: string; content: string }> = []; + const { config } = CLIENTS[client]; + const original = await readInstallTarget(cwd, config); + if (original.isErr()) return Result.err(original.error); + const parsed = parseMcpConfiguration(client, original.value, config); + if (parsed.isErr()) return Result.err(parsed.error); + const existing = parsed.value; + const key = client === "codex" ? "mcp_servers" : "mcpServers"; + if (existing !== undefined) { + const expected = client === "claude" ? { type: "http", url } : { url }; + if (!matchesMcpConnection(existing, expected)) + return Result.err( + new CliStructuredError( + "CLI.AGENT_INSTALL_CONFLICT", + `The Prisma connection in ${config} differs from this installation. Review it before rerunning agent install.`, + ), + ); + } else { + const server = client === "claude" ? { type: "http", url } : { url }; + const originalText = original.value ?? "{}\n"; + const content = + client === "codex" + ? `${original.value ?? ""}\n[mcp_servers.prisma]\nurl = ${JSON.stringify(url)}\n` + : applyEdits( + originalText, + modify(originalText, [key, "prisma"], server, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + eol: "\n", + }, + }), + ); + changes.push({ file: config, content }); + } + return Result.ok(changes); +} + +async function prepareClient( + cwd: string, + client: keyof typeof CLIENTS, + url: string, + enrollmentSkill: string, +) { + const configuration = await prepareMcpConfiguration(cwd, client, url); + if (configuration.isErr()) return Result.err(configuration.error); + const changes = configuration.value; + const skills = CLIENTS[client].skills; + const skillFile = `${skills}/prisma-agent-enrollment/SKILL.md`; + const skill = await readInstallTarget(cwd, skillFile); + if (skill.isErr()) return Result.err(skill.error); + if (skill.value !== null && parseSkillStamp(skill.value).library !== "prisma") + return Result.err( + new CliStructuredError( + "CLI.AGENT_INSTALL_CONFLICT", + `${skillFile} is not a Prisma-managed skill. Move it before rerunning agent install.`, + ), + ); + if (skill.value !== enrollmentSkill) + changes.push({ file: skillFile, content: enrollmentSkill }); + + return Result.ok(changes); +} + +export const agentInstallCommand = defineCommand({ + help: { + summary: + "Install the Prisma MCP connection and enrollment skill in this project", + description: + "Run this before connecting your AI agent to Prisma. Your MCP client handles sign-in and stores the credential. The command preserves other MCP servers and refuses conflicting Prisma configuration. Restart your client after installation, then ask it to connect to Prisma.", + examples: ["agent install", "agent install --client codex"], + }, + args: { + flags: { + client: flag.enum({ + brief: "Configure one MCP client, or all supported clients", + values: ["all", "codex", "claude", "cursor"], + default: "all", + }), + url: flag.string({ + brief: "Use another HTTPS MCP endpoint, such as a preview server", + default: "https://mcp.prisma.io/mcp", + }), + }, + }, + handler: async (args, ctx) => { + const endpoint = Result.try({ + try: () => new URL(args.flags.url ?? "https://mcp.prisma.io/mcp"), + catch: () => + new CliStructuredError( + "CLI.INVALID_ARGUMENTS", + "--url must be an HTTPS MCP URL without credentials or a fragment.", + ), + }); + if (endpoint.isErr()) return notOk(endpoint.error); + if ( + endpoint.value.protocol !== "https:" || + endpoint.value.username || + endpoint.value.password || + endpoint.value.hash + ) + return notOk( + new CliStructuredError( + "CLI.INVALID_ARGUMENTS", + "--url must be an HTTPS MCP URL without credentials or a fragment.", + ), + ); + const url = endpoint.value.href; + const clients = + args.flags.client === "all" || !args.flags.client + ? (Object.keys(CLIENTS) as Array) + : [args.flags.client]; + const enrollmentSkill = import.meta.url.endsWith(`.ts`) + ? await readFile( + new URL( + `../../../../../skills/prisma-agent-enrollment/SKILL.md`, + import.meta.url, + ), + `utf8`, + ) + : ( + await import( + `../../../../../skills/prisma-agent-enrollment/SKILL.md?raw` + ) + ).default; + const changes: Array<{ file: string; content: string }> = []; + const prepared = await Promise.all( + clients.map((client) => + prepareClient(ctx.cwd, client, url, enrollmentSkill), + ), + ); + for (const result of prepared) { + if (result.isErr()) return notOk(result.error); + changes.push(...result.value); + } + const written = await Result.tryPromise({ + try: async () => { + for (const change of changes) { + const target = path.join(ctx.cwd, change.file); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, change.content, "utf8"); + } + }, + catch: () => + new CliStructuredError( + "CLI.AGENT_INSTALL_IO", + "Could not finish installing the agent connection. Check project permissions and rerun agent install.", + ), + }); + if (written.isErr()) return notOk(written.error); + const data = { + url, + clients, + changedFiles: changes.map((change) => change.file), + }; + return ok( + ctx.present( + { data }, + { + json: () => data, + stdout: () => [], + human: () => [ + { + kind: "summary", + status: "ok", + text: changes.length + ? "Installed the Prisma agent connection." + : "The Prisma agent connection is already installed.", + }, + { + kind: "fields", + rows: [ + { label: "clients", value: clients.join(", ") }, + { label: "MCP", value: url }, + ], + }, + ], + next: () => [ + { + kind: "user-choice", + label: + "Restart your MCP client, then ask your agent to connect to Prisma and finish signing in.", + }, + ], + }, + ), + ); + }, +}); diff --git a/packages/cli/src/markdown.d.ts b/packages/cli/src/markdown.d.ts new file mode 100644 index 00000000..83a0b1f3 --- /dev/null +++ b/packages/cli/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md?raw" { + const content: string; + export default content; +} diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index a7e5f58a..b1efc838 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -51,6 +51,8 @@ const ORM_FAMILY_REASON = "ORM command: no management API involved. Real e2e lives in prisma/prisma (R7); the shell proves composition in orm-mount.test.ts (R8)."; const EXCLUSIONS: Readonly> = { + "agent install": + "Writes project-local MCP configuration and a packaged enrollment skill. No management API is involved; e2e/agent-install.e2e.ts verifies the built binary without credentials.", "contract emit": ORM_FAMILY_REASON, "contract infer": ORM_FAMILY_REASON, "db init": ORM_FAMILY_REASON, diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index d1275329..b1e7d1bb 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -20,6 +20,7 @@ import { defineCommand, telemetryCommandGroup } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { describe, expect, it } from "vitest"; import { + agentCommandFamily, cliGroups, composerCommandFamily, mountedCommands, @@ -75,6 +76,7 @@ function unownedMountPaths( * adding its path here. */ const EXPECTED_MOUNT_PATHS: readonly string[] = [ + "agent install", "auth login", "auth logout", "auth whoami", @@ -165,6 +167,7 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ ]; const MOUNTED_FAMILIES = { + agent: agentCommandFamily, platform: platformCommandFamily, composer: composerCommandFamily, orm: ormCommandFamily, diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 79d8d991..da53a8c6 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,3 +1,4 @@ +import { dirname, resolve } from "node:path"; import { defineConfig } from "tsdown"; export default defineConfig([ @@ -14,6 +15,17 @@ export default defineConfig([ }, format: ["esm"], clean: true, + inputOptions: { moduleTypes: { ".md": "text" } }, + plugins: [ + { + name: "markdown-text", + resolveId(source, importer) { + if (source.endsWith(`.md?raw`) && importer) + return resolve(dirname(importer), source.slice(0, -4)); + return null; + }, + }, + ], shims: true, fixedExtension: false, deps: { diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 15d4ebc1..c84f934b 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -60,7 +60,9 @@ "cross-spawn": "^7.0.6", "dotenv": "^17.4.2", "execa": "^9.6.1", - "open": "^11.0.0" + "jsonc-parser": "^3.3.1", + "open": "^11.0.0", + "smol-toml": "^1.9.0" }, "devDependencies": { "@prisma/cli": "workspace:8.0.0-rc.19", diff --git a/packages/prisma/tsconfig.json b/packages/prisma/tsconfig.json index a589c6a4..aff28fe4 100644 --- a/packages/prisma/tsconfig.json +++ b/packages/prisma/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@repo/tsconfig/base.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "../cli/src/markdown.d.ts"] } diff --git a/packages/prisma/tsdown.config.ts b/packages/prisma/tsdown.config.ts index 3f7a7400..b78175af 100644 --- a/packages/prisma/tsdown.config.ts +++ b/packages/prisma/tsdown.config.ts @@ -1,3 +1,4 @@ +import { dirname, resolve } from "node:path"; import { defineConfig } from "tsdown"; export default defineConfig([ @@ -14,6 +15,17 @@ export default defineConfig([ }, format: ["esm"], clean: true, + inputOptions: { moduleTypes: { ".md": "text" } }, + plugins: [ + { + name: "markdown-text", + resolveId(source, importer) { + if (source.endsWith(`.md?raw`) && importer) + return resolve(dirname(importer), source.slice(0, -4)); + return null; + }, + }, + ], shims: true, fixedExtension: false, deps: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bfc84b7..0f278c72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,15 @@ importers: execa: specifier: ^9.6.1 version: 9.6.1 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 open: specifier: ^11.0.0 version: 11.0.0 + smol-toml: + specifier: ^1.9.0 + version: 1.9.0 devDependencies: '@prisma/composer': specifier: 0.25.0 @@ -239,9 +245,15 @@ importers: execa: specifier: ^9.6.1 version: 9.6.1 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 open: specifier: ^11.0.0 version: 11.0.0 + smol-toml: + specifier: ^1.9.0 + version: 1.9.0 devDependencies: '@prisma/cli': specifier: workspace:8.0.0-rc.19 @@ -2743,6 +2755,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5415,6 +5431,8 @@ snapshots: sisteransi@1.0.5: {} + smol-toml@1.9.0: {} + source-map-js@1.2.1: {} sparse-bitfield@3.0.3: diff --git a/skills/prisma-agent-enrollment/SKILL.md b/skills/prisma-agent-enrollment/SKILL.md new file mode 100644 index 00000000..1f253199 --- /dev/null +++ b/skills/prisma-agent-enrollment/SKILL.md @@ -0,0 +1,85 @@ +--- +name: prisma-agent-enrollment +metadata: + library: "prisma" + library_version: "8.0.0-rc.19" + version: 2026.9.30 +description: >- + Connect an AI agent to Prisma through the Prisma MCP server. Use for agent + enrollment, workspace access, approval requests, or any Prisma Platform + operation performed by an enrolled agent. +--- + +# Connect to Prisma + +Use the Prisma MCP connection for cloud operations. The MCP client owns OAuth +sign-in and credential storage. Do not request a person's API token, read the +CLI's human credentials, put credentials in chat, or call the Management API +directly to bypass this connection's policy. + +## Install and sign in + +1. If the Prisma MCP connection is missing, run `prisma agent install` in the + project. Use `--client codex`, `--client claude`, or `--client cursor` to + configure only that client. Restart the client if it does not reload MCP + configuration automatically. +2. Connect to `https://mcp.prisma.io/mcp` through the client's OAuth sign-in. + Present the sign-in link when the client asks. The person signs in and + authorizes the connection; the client stores and refreshes its credential. +3. Call `get_agent_connection`. This confirms the enrolled agent identity, + allowed workspace IDs, and permission policy. Reconnecting the same OAuth + client reuses the agent. Do not create another identity to escape a pause + or a revoked connection. +4. Start with the default workspace. Use a different `workspaceId` on tools + only when it appears in the connection's allowed workspace list. The sponsor + changes this list in Console under Settings → Agents → Permissions. + +Discover the available MCP tools and use them for projects, branches, +databases, compute apps and deployments, buckets, queries, and schema changes. +Do not assume a tool exists: inspect its schema before calling it. If an +operation is unavailable, explain the missing capability instead of switching +silently to a person's CLI session or direct API requests. Local code editing, +builds, and tests do not need cloud credentials. + +## Wait for approval + +A tool can return `approval_required` with `approvalId`, `approveUrl`, and +`expiresAt`. Show the approval link and one sentence explaining the action. +Keep working on independent tasks while the decision is pending. + +When the harness supports MCP Events and the server advertises `events`, use +its native event subscription to `prisma.approval.resolved`, filtered by +`approvalId`. The harness supplies the verified HTTPS callback and signing +secret. Let the harness verify signatures and manage refresh and unsubscribe. +Do not invent a callback URL, create a public receiver, or assume that ordinary +MCP support includes Events support. After subscribing, read +`get_agent_approval` once so a decision made before subscription is not missed. + +If Events is unavailable, call `get_agent_approval` every five seconds until +`approved`, `approved_window`, `denied`, or `expired`. Stop at the returned +expiry time. Never ask the person to type "done" to signal a decision. + +After approval, retry the original tool with the exact same arguments and +`approvalId`. An approval cannot authorize changed arguments. A one-hour grant +can cover other actions of the same kind in its workspace, project, and branch +role; retry blocked actions through MCP so the server decides which are covered. +After denial, stop that action. After expiry, read the status again before any +retry; a late approval may still be valid. Do not repeatedly create new requests +without telling the person why the old request expired. + +Use `reason` for a short explanation on sensitive operations. Never include +secrets or connection strings in it. Pause and revocation take effect on the +next request. Membership removal also removes access, even if the workspace +still appears in an older tool response. + +## GitHub deployment + +Deploy from the linked repository's GitHub Actions workflow. The workflow uses +`prisma/cloud-deploy-action@v1` with `id-token: write` and GitHub OIDC. Do not +store a Prisma service token in the repository. Inspect MCP tool descriptions +for the connection and deployment steps available on this server. + +The legacy device enrollment flow remains available for clients that explicitly +support it. Use its agent credential through MCP; do not borrow a human CLI +session. A device client must store its credential securely and respect its +returned polling interval and expiry. diff --git a/skills/prisma-platform-core-concepts/SKILL.md b/skills/prisma-platform-core-concepts/SKILL.md index c24971fb..d003ec02 100644 --- a/skills/prisma-platform-core-concepts/SKILL.md +++ b/skills/prisma-platform-core-concepts/SKILL.md @@ -36,6 +36,15 @@ needs them. To learn a command surface, run `prisma --help`, or queries belong to the `prisma-orm-core-concepts` skill; declaring services and modules in code belongs to `prisma-composer-core-concepts`. +## Agent connections + +When operating as an enrolled agent, read `prisma-agent-enrollment` and use +Prisma MCP for cloud operations. Install it with `prisma agent install`. +The MCP client handles OAuth and secure credential storage. Use its allowed +workspace list and approval responses; do not use a person's CLI credentials +or direct Management API calls as a fallback. Local builds and code changes +can still use the CLI without a cloud credential. + ## The stack One CLI, `prisma`, fronts a set of products designed to be used together: From 63ceb4aa8d00f802c9eb17f5ecbebf5014fa520d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Thu, 1 Oct 2026 12:33:25 +0700 Subject: [PATCH 2/2] fix(cli): finish agent installation across clients Address CodeRabbit findings by validating proposed TOML, replacing files atomically, and using the configured MCP endpoint during sign-in. Add Pi installation with the existing project configuration pattern and built-binary coverage. --- docs/product/agent-install.md | 19 +++++-- packages/cli/e2e/agent-install.e2e.ts | 53 ++++++++++++++++- packages/cli/src/commands/agent/install.ts | 66 +++++++++++++++------- skills/prisma-agent-enrollment/SKILL.md | 6 +- 4 files changed, 115 insertions(+), 29 deletions(-) diff --git a/docs/product/agent-install.md b/docs/product/agent-install.md index c4028045..1631dbef 100644 --- a/docs/product/agent-install.md +++ b/docs/product/agent-install.md @@ -4,10 +4,10 @@ skill to the current project. It does not sign in, create an API token, or use the CLI's human credentials. OAuth sign-in happens in the MCP client. -The default configures Codex, Claude Code, and Cursor. `--client codex`, -`--client claude`, or `--client cursor` installs only that client. Codex uses +The default configures Codex, Claude Code, Pi, and Cursor. `--client codex`, +`--client claude`, `--client pi`, or `--client cursor` installs only that client. Codex uses `.codex/config.toml` and `.agents/skills`; Claude Code uses `.mcp.json` and -`.claude/skills`; Cursor uses `.cursor/mcp.json` and `.cursor/skills`. +`.claude/skills`; Pi uses `.pi/mcp.json` and `.pi/skills`; Cursor uses `.cursor/mcp.json` and `.cursor/skills`. `--url` selects another HTTPS MCP endpoint, for example a preview server. The production default is `https://mcp.prisma.io/mcp`. No authorization headers @@ -17,11 +17,14 @@ The command preserves other MCP servers, comments, and unrelated configuration. An existing Prisma connection with another URL, headers, or different transport is refused. Invalid configuration, a symlink target, and an enrollment skill not owned by Prisma are also refused. It checks every target before writing any -file. Repeating the command with the same endpoint is safe. +file. Repeating the command with the same endpoint is safe. Codex configurations that +use an inline `mcp_servers` table must be converted to normal TOML tables before +installation. The command validates the proposed configuration before writing it. Human output lists the configured clients and the next sign-in step. JSON output -returns the endpoint, clients, and changed file paths. Partial filesystem writes -can occur if a write fails; the error says to correct the filesystem problem and +returns the endpoint, clients, and changed file paths. Each file is written to a temporary file in the same directory and renamed only +after the write succeeds. Existing file permissions are preserved. Some client +files can remain unchanged if a later file fails; the error says to correct the filesystem problem and rerun the command. The built-binary filesystem test proves the installation without requiring a platform credential. @@ -29,3 +32,7 @@ New MCP connections enroll a persistent agent with access to the sponsor's default workspace. The sponsor can change the workspace list in Console. The skill prefers native MCP Events for approvals when the client supports them, and otherwise uses a bounded status check every five seconds. + +Pi requires a version with native remote MCP and OAuth support. Run `pi mcp login prisma` +after trusting the project configuration, then `/reload` in an existing session. +See [Pi MCP setup](https://pi.dev/docs/latest/mcp). diff --git a/packages/cli/e2e/agent-install.e2e.ts b/packages/cli/e2e/agent-install.e2e.ts index fe63a92a..92635619 100644 --- a/packages/cli/e2e/agent-install.e2e.ts +++ b/packages/cli/e2e/agent-install.e2e.ts @@ -1,10 +1,12 @@ // biome-ignore-all lint/performance/noAwaitInLoops: each assertion checks an installed client directory. import { execFile } from "node:child_process"; import { + chmod, mkdir, mkdtemp, readFile, rm, + stat, symlink, writeFile, } from "node:fs/promises"; @@ -60,7 +62,7 @@ describe("agent install through the built binary", () => { prisma: { type: "http", url: "https://mcp.prisma.io/mcp" }, }, }); - for (const dir of [".agents", ".claude", ".cursor"]) { + for (const dir of [".agents", ".claude", ".pi", ".cursor"]) { expect( await readFile( path.join(cwd, dir, "skills/prisma-agent-enrollment/SKILL.md"), @@ -137,3 +139,52 @@ describe("agent install through the built binary", () => { ).rejects.toMatchObject({ code: "ENOENT" }); }); }); + +it("refuses an inline Codex MCP table without changing any file", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, `.codex`)); + const content = `mcp_servers = { docs = { url = "https://example.com/mcp" } }\n`; + await writeFile(path.join(cwd, `.codex/config.toml`), content); + await expect(install(cwd)).rejects.toMatchObject({ code: 2 }); + expect(await readFile(path.join(cwd, `.codex/config.toml`), `utf8`)).toBe( + content, + ); + await expect(readFile(path.join(cwd, `.mcp.json`))).rejects.toMatchObject({ + code: `ENOENT`, + }); +}); + +it("installs Pi with a preview endpoint and preserves existing file permissions", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, `.pi`)); + const target = path.join(cwd, `.pi/mcp.json`); + await writeFile( + target, + `{"mcpServers":{"docs":{"url":"https://example.com/mcp"}}}`, + ); + await chmod(target, 0o600); + await install(cwd, [ + `--client`, + `pi`, + `--url`, + `https://preview.example.com/mcp`, + ]); + expect(JSON.parse(await readFile(target, `utf8`))).toMatchObject({ + mcpServers: { + prisma: { url: `https://preview.example.com/mcp` }, + docs: { url: `https://example.com/mcp` }, + }, + }); + expect((await stat(target)).mode & 0o777).toBe(0o600); + const skill = await readFile( + path.join(cwd, `.pi/skills/prisma-agent-enrollment/SKILL.md`), + `utf8`, + ); + expect(skill).toContain(`configured Prisma MCP connection's OAuth sign-in`); + await install(cwd, [ + `--client`, + `pi`, + `--url`, + `https://preview.example.com/mcp`, + ]); +}); diff --git a/packages/cli/src/commands/agent/install.ts b/packages/cli/src/commands/agent/install.ts index cc215e01..46ef6806 100644 --- a/packages/cli/src/commands/agent/install.ts +++ b/packages/cli/src/commands/agent/install.ts @@ -1,5 +1,6 @@ // biome-ignore-all lint/performance/noAwaitInLoops: parent checks and file writes must finish in order. -import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"; import path from "node:path"; import { defineCommand, flag } from "@prisma/cli-engine"; import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; @@ -16,6 +17,7 @@ import { parseSkillStamp } from "../../lib/skills/frontmatter"; const CLIENTS = { codex: { config: ".codex/config.toml", skills: ".agents/skills" }, claude: { config: ".mcp.json", skills: ".claude/skills" }, + pi: { config: ".pi/mcp.json", skills: ".pi/skills" }, cursor: { config: ".cursor/mcp.json", skills: ".cursor/skills" }, } as const; @@ -122,24 +124,32 @@ async function prepareMcpConfiguration( `The Prisma connection in ${config} differs from this installation. Review it before rerunning agent install.`, ), ); - } else { - const server = client === "claude" ? { type: "http", url } : { url }; - const originalText = original.value ?? "{}\n"; - const content = - client === "codex" - ? `${original.value ?? ""}\n[mcp_servers.prisma]\nurl = ${JSON.stringify(url)}\n` - : applyEdits( - originalText, - modify(originalText, [key, "prisma"], server, { - formattingOptions: { - insertSpaces: true, - tabSize: 2, - eol: "\n", - }, - }), - ); - changes.push({ file: config, content }); + return Result.ok(changes); } + const server = client === "claude" ? { type: "http", url } : { url }; + const originalText = original.value ?? "{}\n"; + const content = + client === "codex" + ? `${original.value ?? ""}\n[mcp_servers.prisma]\nurl = ${JSON.stringify(url)}\n` + : applyEdits( + originalText, + modify(originalText, [key, "prisma"], server, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + eol: "\n", + }, + }), + ); + const proposed = parseMcpConfiguration(client, content, config); + if (proposed.isErr()) + return Result.err( + new CliStructuredError( + `CLI.AGENT_INSTALL_CONFIG`, + `Could not extend ${config} safely. Convert inline MCP tables to normal TOML tables and rerun agent install.`, + ), + ); + changes.push({ file: config, content }); return Result.ok(changes); } @@ -181,7 +191,7 @@ export const agentInstallCommand = defineCommand({ flags: { client: flag.enum({ brief: "Configure one MCP client, or all supported clients", - values: ["all", "codex", "claude", "cursor"], + values: ["all", "codex", "claude", "pi", "cursor"], default: "all", }), url: flag.string({ @@ -245,7 +255,23 @@ export const agentInstallCommand = defineCommand({ for (const change of changes) { const target = path.join(ctx.cwd, change.file); await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, change.content, "utf8"); + const info = await lstat(target).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === `ENOENT`) return null; + throw error; + }, + ); + const temporary = `${target}.${randomUUID()}.tmp`; + try { + await using file = await open(temporary, `wx`, info?.mode ?? 0o644); + await file.writeFile(change.content, `utf8`); + if (info) await file.chmod(info.mode); + await file.sync(); + await file.close(); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } } }, catch: () => diff --git a/skills/prisma-agent-enrollment/SKILL.md b/skills/prisma-agent-enrollment/SKILL.md index 1f253199..0831f487 100644 --- a/skills/prisma-agent-enrollment/SKILL.md +++ b/skills/prisma-agent-enrollment/SKILL.md @@ -20,10 +20,12 @@ directly to bypass this connection's policy. ## Install and sign in 1. If the Prisma MCP connection is missing, run `prisma agent install` in the - project. Use `--client codex`, `--client claude`, or `--client cursor` to + project. Use `--client codex`, `--client claude`, `--client pi`, or `--client cursor` to configure only that client. Restart the client if it does not reload MCP configuration automatically. -2. Connect to `https://mcp.prisma.io/mcp` through the client's OAuth sign-in. +2. Use the configured Prisma MCP connection's OAuth sign-in. The installer defaults + to `https://mcp.prisma.io/mcp`; `--url` can select a preview endpoint. Keep using + that configured endpoint for sign-in and cloud operations. Present the sign-in link when the client asks. The person signs in and authorizes the connection; the client stores and refreshes its credential. 3. Call `get_agent_connection`. This confirms the enrolled agent identity,