diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f4e73..d0ad3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Feat: `list_shared_projects` tool to fetch projects that user has permissions to collaborate on - Feat: `reset_from_parent` tool to reset a branch from its parent's current state +- Feat: `compare_database_schema` tool to compare the schema from the child branch and its parent # [0.6.4] 2025-08-22 diff --git a/README.md b/README.md index a1e89c6..2b26d06 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ The Neon MCP Server provides the following actions, which are exposed as "tools" - **`list_branch_computes`**: Lists compute endpoints for a project or specific branch, including compute ID, type, size, and autoscaling information. - **`list_organizations`**: Lists all organizations that the current user has access to. Optionally filter by organization name or ID using the search parameter. - **`reset_from_parent`**: Resets the current branch to its parent's state, discarding local changes. Automatically preserves to backup if branch has children, or optionally preserve on request with a custom name. +- **`compare_database_schema`**: Shows the schema diff between the child branch and its parent **SQL Query Execution:** diff --git a/landing/components/DescriptionItem.tsx b/landing/components/DescriptionItem.tsx index 0eeaf35..ca65c8e 100644 --- a/landing/components/DescriptionItem.tsx +++ b/landing/components/DescriptionItem.tsx @@ -13,6 +13,8 @@ import { import { Terminal, CircleAlert, + Lightbulb, + BadgeInfo, Workflow, SquareArrowRight, Component, @@ -30,6 +32,8 @@ const ALERT_VARIANT_PER_DESCRIPTION_TYPE: Record< next_steps: { variant: 'default', icon: SquareArrowRight }, important_notes: { variant: 'important', icon: CircleAlert }, workflow: { variant: 'default', icon: Workflow }, + hints: { variant: 'default', icon: BadgeInfo }, + hint: { variant: 'default', icon: Lightbulb }, instructions: { variant: 'default', icon: Terminal }, response_instructions: { variant: 'default', icon: Terminal }, example: { variant: 'default', icon: Terminal }, diff --git a/landing/lib/description.ts b/landing/lib/description.ts index b7125ed..d3c7f11 100644 --- a/landing/lib/description.ts +++ b/landing/lib/description.ts @@ -10,6 +10,8 @@ const POSSIBLE_TYPES = [ 'example', 'do_not_include', 'error_handling', + 'hint', + 'hints', ] as const; export type DescriptionItemType = (typeof POSSIBLE_TYPES)[number]; @@ -134,7 +136,7 @@ export function parseDescription(description: string): DescriptionItem[] { while (rest.length > 0) { const match = rest.match( - /<(use_case|workflow|important_notes|next_steps|response_instructions|instructions|example|do_not_include|error_handling)>(.*?)<\/\1>/s, + /<(use_case|workflow|important_notes|next_steps|response_instructions|instructions|example|do_not_include|error_handling|hints?)>(.*?)<\/\1>/s, ); if (!match) { diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index f5e6050..914041c 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -23,6 +23,7 @@ import { listOrganizationsInputSchema, listSharedProjectsInputSchema, resetFromParentInputSchema, + compareDatabaseSchemaInputSchema, } from './toolsSchema.js'; export const NEON_TOOLS = [ @@ -608,4 +609,273 @@ export const NEON_TOOLS = [ description: 'Lists compute endpoints for a project or specific branch', inputSchema: listBranchComputesInputSchema, }, + { + name: 'compare_database_schema' as const, + description: ` + + Use this tool to compare the schema of a database between two branches. + The output of the tool is a JSON object with one field: \`diff\`. + + + \`\`\`json + { + "diff": "--- a/neondb\n+++ b/neondb\n@@ -27,7 +27,10 @@\n \n CREATE TABLE public.users (\n id integer NOT NULL,\n- username character varying(50) NOT NULL\n+ username character varying(50) NOT NULL,\n+ is_deleted boolean DEFAULT false NOT NULL,\n+ created_at timestamp with time zone DEFAULT now() NOT NULL,\n+ updated_at timestamp with time zone\n );\n \n \n@@ -79,6 +82,13 @@\n \n \n --\n+-- Name: users_created_at_idx; Type: INDEX; Schema: public; Owner: neondb_owner\n+--\n+\n+CREATE INDEX users_created_at_idx ON public.users USING btree (created_at DESC) WHERE (is_deleted = false);\n+\n+\n+--\n -- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: cloud_admin\n --\n \n" + } + \`\`\` + + + At this field you will find a difference between two schemas. + The diff represents the changes required to make the parent branch schema match the child branch schema. + The diff field contains a unified diff (git-style patch) as a string. + + You MUST be able to generate a zero-downtime migration from the diff and apply it to the parent branch. + (This branch is a child and has a parent. You can get parent id just querying the branch details.) + + + + To generate schema diff, you MUST SPECIFY the \`database_name\`. + If \`database_name\` is not specified, you MUST fall back to the default database name: \`${NEON_DEFAULT_DATABASE_NAME}\`. + + You MUST TAKE INTO ACCOUNT the PostgreSQL version. The PostgreSQL version is the same for both branches. + You MUST ASK user consent before running each generated SQL query. + You SHOULD USE \`run_sql\` tool to run each generated SQL query. + You SHOULD suggest creating a backup or point-in-time restore before running the migration. + Generated queries change the schema of the parent branch and MIGHT BE dangerous to execute. + Generated SQL migrations SHOULD be idempotent where possible (i.e., safe to run multiple times without failure) and include \`IF NOT EXISTS\` / \`IF EXISTS\` where applicable. + You SHOULD recommend including comments in generated SQL linking back to diff hunks (e.g., \`-- from diff @@ -27,7 +27,10 @@\`) to make audits easier. + Generated SQL should be reviewed for dependencies (e.g., foreign key order) before execution. + + + + After executing this tool, you MUST follow these steps: + 1. Review the schema diff and suggest generating a zero-downtime migration. + 2. Follow these instructions to respond to the client: + + + + Provide brief information about the changes: + * Tables + * Views + * Indexes + * Ownership + * Constraints + * Triggers + * Policies + * Extensions + * Schemas + * Sequences + * Tablespaces + * Users + * Roles + * Privileges + + + + 3. If a migration fails, you SHOULD guide the user on how to revert the schema changes, for example by using backups, point-in-time restore, or generating reverse SQL statements (if safe). + + + This tool: + 1. Generates a diff between the child branch and its parent. + 2. Generates a SQL migration from the diff. + 3. Suggest generating zero-downtime migration. + + + 1. User asks you to generate a diff between two branches. + 2. You suggest generating a SQL migration from the diff. + 3. Ensure the generated migration is zero-downtime; otherwise, warn the user. + 4. You ensure that your suggested migration is also matching the PostgreSQL version. + 5. You use \`run_sql\` tool to run each generated SQL query and ask the user consent before running it. + Before requesting user consent, present a summary of all generated SQL statements along with their potential impact (e.g., table rewrites, lock risks, validation steps) so the user can make an informed decision. + 6. Propose to rerun the schema diff tool one more time to ensure that the migration is applied correctly. + 7. If the diff is empty, confirm that the parent schema now matches the child schema. + 8. If the diff is not empty after migration, warn the user and assist in resolving the remaining differences. + + + + + Adding the column with a \`DEFAULT\` static value will not have any locks. + But if the function is called that is not deterministic, it will have locks. + + + \`\`\`sql + -- No table rewrite, minimal lock time + ALTER TABLE users ADD COLUMN status text DEFAULT 'active'; + \`\`\` + + + There is an example of a case where the function is not deterministic and will have locks: + + + \`\`\`sql + -- Table rewrite, potentially longer lock time + ALTER TABLE users ADD COLUMN created_at timestamptz DEFAULT now(); + \`\`\` + + The fix for this is next: + + \`\`\`sql + -- Adding a nullable column first + ALTER TABLE users ADD COLUMN created_at timestamptz; + + -- Setting the default value because the rows are updated + UPDATE users SET created_at = now(); + \`\`\` + + + + + Adding constraints in two phases (including foreign keys) + + + \`\`\`sql + -- Step 1: Add constraint without validating existing data + -- Fast - only blocks briefly to update catalog + ALTER TABLE users ADD CONSTRAINT users_age_positive + CHECK (age > 0) NOT VALID; + + -- Step 2: Validate existing data (can take time but doesn't block writes) + -- Uses SHARE UPDATE EXCLUSIVE lock - allows reads/writes + ALTER TABLE users VALIDATE CONSTRAINT users_age_positive; + \`\`\` + + + + \`\`\`sql + -- Step 1: Add foreign key without validation + -- Fast - only updates catalog, doesn't validate existing data + ALTER TABLE orders ADD CONSTRAINT orders_user_id_fk + FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; + + -- Step 2: Validate existing relationships + -- Can take time but allows concurrent operations + ALTER TABLE orders VALIDATE CONSTRAINT orders_user_id_fk; + \`\`\` + + + + + Setting columns to NOT NULL + + + \`\`\`sql + -- Step 1: Add a check constraint (fast with NOT VALID) + ALTER TABLE users ADD CONSTRAINT users_email_not_null + CHECK (email IS NOT NULL) NOT VALID; + + -- Step 2: Validate the constraint (allows concurrent operations) + ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null; + + -- Step 3: Set NOT NULL (fast since constraint guarantees no nulls) + ALTER TABLE users ALTER COLUMN email SET NOT NULL; + + -- Step 4: Drop the redundant check constraint + ALTER TABLE users DROP CONSTRAINT users_email_not_null; + \`\`\` + + + + For PostgreSQL v18+ + (to get PostgreSQL version, you can use \`describe_project\` tool or \`run_sql\` tool and execute \`SELECT version();\` query) + + \`\`\`sql + -- PostgreSQL 18+ - Simplified approach + ALTER TABLE users ALTER COLUMN email SET NOT NULL NOT VALID; + ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null; + \`\`\` + + + + + In some cases, you need to combine two approaches to achieve a zero-downtime migration. + + + \`\`\`sql + -- Step 1: Adding a nullable column first + ALTER TABLE users ADD COLUMN created_at timestamptz; + + -- Step 2: Updating the all rows with the default value + UPDATE users SET created_at = now() WHERE created_at IS NULL; + + -- Step 3: Creating a not null constraint + ALTER TABLE users ADD CONSTRAINT users_created_at_not_null + CHECK (created_at IS NOT NULL) NOT VALID; + + -- Step 4: Validating the constraint + ALTER TABLE users VALIDATE CONSTRAINT users_created_at_not_null; + + -- Step 5: Setting the column to NOT NULL + ALTER TABLE users ALTER COLUMN created_at SET NOT NULL; + + -- Step 6: Dropping the redundant NOT NULL constraint + ALTER TABLE users DROP CONSTRAINT users_created_at_not_null; + + -- Step 7: Adding the default value + ALTER TABLE users ALTER COLUMN created_at SET DEFAULT now(); + \`\`\` + + + For PostgreSQL v18+ + + \`\`\`sql + -- Step 1: Adding a nullable column first + ALTER TABLE users ADD COLUMN created_at timestamptz; + + -- Step 2: Updating the all rows with the default value + UPDATE users SET created_at = now() WHERE created_at IS NULL; + + -- Step 3: Creating a not null constraint + ALTER TABLE users ALTER COLUMN created_at SET NOT NULL NOT VALID; + + -- Step 4: Validating the constraint + ALTER TABLE users VALIDATE CONSTRAINT users_created_at_not_null; + + -- Step 5: Adding the default value + ALTER TABLE users ALTER COLUMN created_at SET DEFAULT now(); + \`\`\` + + + + + Create index CONCURRENTLY + + + \`\`\`sql + CREATE INDEX CONCURRENTLY idx_users_email ON users (email); + \`\`\` + + + + + Drop index CONCURRENTLY + + + \`\`\`sql + DROP INDEX CONCURRENTLY idx_users_email; + \`\`\` + + + + + Create materialized view WITH NO DATA + + + \`\`\`sql + CREATE MATERIALIZED VIEW mv_users AS SELECT name FROM users WITH NO DATA; + \`\`\` + + + + + Refresh materialized view CONCURRENTLY + + + \`\`\`sql + REFRESH MATERIALIZED VIEW CONCURRENTLY mv_users; + \`\`\` + + + + `, + inputSchema: compareDatabaseSchemaInputSchema, + }, ]; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 864b441..5f3a8d5 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -4,6 +4,7 @@ import { EndpointType, ListProjectsParams, ListSharedProjectsParams, + GetProjectBranchSchemaComparisonParams, Organization, ProjectCreateRequest, } from '@neondatabase/api-client'; @@ -1212,6 +1213,14 @@ async function handleListSharedProjects( return response.data.projects; } +async function handleCompareDatabaseSchema( + params: GetProjectBranchSchemaComparisonParams, + neonClient: Api, +) { + const response = await neonClient.getProjectBranchSchemaComparison(params); + return response.data; +} + export const NEON_HANDLERS = { list_projects: async ({ params }, neonClient, extra) => { const organization = await getOrgByOrgIdOrDefault( @@ -1774,4 +1783,18 @@ export const NEON_HANDLERS = { ], }; }, + + compare_database_schema: async ({ params }, neonClient) => { + const result = await handleCompareDatabaseSchema( + { + projectId: params.projectId, + branchId: params.branchId, + db_name: params.databaseName, + }, + neonClient, + ); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + }, } satisfies ToolHandlers; diff --git a/src/tools/toolsSchema.ts b/src/tools/toolsSchema.ts index 855aaf0..95e14ba 100644 --- a/src/tools/toolsSchema.ts +++ b/src/tools/toolsSchema.ts @@ -334,3 +334,9 @@ export const resetFromParentInputSchema = z.object({ 'Optional name to preserve the current state under a new branch before resetting', ), }); + +export const compareDatabaseSchemaInputSchema = z.object({ + projectId: z.string().describe('The ID of the project'), + branchId: z.string().describe('The ID of the branch'), + databaseName: z.string().describe(DATABASE_NAME_DESCRIPTION), +});