This is page 1 of 2. Use http://codebase.md/neondatabase-labs/mcp-server-neon?page={x} to view the full context.
# Directory Structure
```
├── .bun-version
├── .dockerignore
├── .env.example
├── .github
│ └── workflows
│ ├── claude-code-review.yml
│ ├── claude.yml
│ ├── koyeb-preview.yml
│ ├── koyeb-prod.yml
│ └── pr.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── AGENTS.md
├── bun.lock
├── CHANGELOG.md
├── CLAUDE.md
├── Dockerfile
├── eslint.config.js
├── landing
│ ├── .gitignore
│ ├── app
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components
│ │ ├── CodeSnippet.tsx
│ │ ├── CopyableUrl.tsx
│ │ ├── DescriptionItem.tsx
│ │ ├── ExternalIcon.tsx
│ │ ├── ExternalLink.tsx
│ │ ├── Header.tsx
│ │ ├── Introduction.tsx
│ │ ├── ThemeProvider.tsx
│ │ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert.tsx
│ │ └── button.tsx
│ ├── components.json
│ ├── eslint.config.mjs
│ ├── icons
│ │ ├── github.svg
│ │ └── neon.svg
│ ├── lib
│ │ ├── description.ts
│ │ └── utils.ts
│ ├── next.config.ts
│ ├── package-lock.json
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ │ └── favicon.ico
│ ├── README.md
│ └── tsconfig.json
├── LICENSE
├── mcp-client
│ ├── .env.example
│ ├── package-lock.json
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── bin.ts
│ │ ├── cli-client.ts
│ │ ├── index.ts
│ │ ├── logger.ts
│ │ └── neon-cli-client.ts
│ └── tsconfig.json
├── package-lock.json
├── package.json
├── public
│ └── logo.png
├── PUBLISH.md
├── README.md
├── remote.Dockerfile
├── scripts
│ └── before-publish.ts
├── src
│ ├── analytics
│ │ └── analytics.ts
│ ├── constants.ts
│ ├── describeUtils.ts
│ ├── index.ts
│ ├── initConfig.ts
│ ├── oauth
│ │ ├── client.ts
│ │ ├── cookies.ts
│ │ ├── kv-store.ts
│ │ ├── model.ts
│ │ ├── server.ts
│ │ └── utils.ts
│ ├── prompts.ts
│ ├── resources.ts
│ ├── sentry
│ │ ├── instrument.ts
│ │ └── utils.ts
│ ├── server
│ │ ├── api.ts
│ │ ├── errors.ts
│ │ └── index.ts
│ ├── tools
│ │ ├── definitions.ts
│ │ ├── handlers
│ │ │ ├── connection-string.ts
│ │ │ ├── decribe-project.ts
│ │ │ ├── describe-branch.ts
│ │ │ ├── fetch.ts
│ │ │ ├── list-orgs.ts
│ │ │ ├── list-projects.ts
│ │ │ ├── neon-auth.ts
│ │ │ ├── search.ts
│ │ │ ├── urls.ts
│ │ │ └── utils.ts
│ │ ├── index.ts
│ │ ├── state.ts
│ │ ├── tools.ts
│ │ ├── toolsSchema.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── tools-evaluations
│ │ ├── evalUtils.ts
│ │ └── prepare-database-migration.eval.ts
│ ├── transports
│ │ ├── sse-express.ts
│ │ ├── stdio.ts
│ │ └── stream.ts
│ ├── types
│ │ ├── auth.ts
│ │ ├── context.ts
│ │ ├── express.d.ts
│ │ └── helpers.ts
│ ├── utils
│ │ ├── client-application.ts
│ │ ├── logger.ts
│ │ └── polyfills.ts
│ └── views
│ ├── approval-dialog.pug
│ └── styles.css
├── tsconfig.json
└── tsconfig.test.json
```
# Files
--------------------------------------------------------------------------------
/.bun-version:
--------------------------------------------------------------------------------
```
1.2.13
```
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
```
v22.15.1
```
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
```
save-exact=true
```
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
```
build/
landing/.next
landing/out
```
--------------------------------------------------------------------------------
/mcp-client/.env.example:
--------------------------------------------------------------------------------
```
ANTHROPIC_API_KEY=
NEON_API_KEY=
```
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
```
{
"printWidth": 80,
"singleQuote": true,
"semi": true
}
```
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
**/node_modules
**/*.log
**/.env
**/dist
**/build
# VS Code history extension
**/.history
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
node_modules/
*.log
.env
dist/
build/
# landing page generated files are placed in root /public directory,
# so ignoring it in order to do not commit something unintentionally.
# if something should be added in /public it can be done by force add:
# git add -f public/somefile
/public/
# IDE stuff
.history
.idea
```
--------------------------------------------------------------------------------
/landing/.gitignore:
--------------------------------------------------------------------------------
```
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# part of building process
tools.json
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
## BrainTrust
BRAINTRUST_API_KEY=
## Neon BaaS team org api key
NEON_API_KEY=
## Anthropic api key to run the evals
ANTHROPIC_API_KEY=
## Neon API
NEON_API_HOST=https://api.neon.tech/api/v2
## OAuth upstream oauth host
UPSTREAM_OAUTH_HOST='https://oauth2.neon.tech';
## OAuth client id
CLIENT_ID=
## OAuth client secret
CLIENT_SECRET=
## Redirect URI for OIDC callback
REDIRECT_URI=http://localhost:3001/callback
## A connection string to postgres database for client and token persistence
## Optional while running in MCP in stdio
OAUTH_DATABASE_URL=
## A secret key to sign and verify the cookies
## Optional while running MCP in stdio
COOKIE_SECRET=
## Optional Analytics
ANALYTICS_WRITE_KEY=
## Optional Sentry
SENTRY_DSN=
```
--------------------------------------------------------------------------------
/mcp-client/README.md:
--------------------------------------------------------------------------------
```markdown
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
<source media="(prefers-color-scheme: light)" srcset="https://neon.com/brand/neon-logo-light-color.svg">
<img width="250px" alt="Neon Logo fallback" src="https://neon.com/brand/neon-logo-dark-color.svg">
</picture>
## MCP Client CLI
This is a CLI client that can be used to interact with any MCP server and its tools. For more, see [Building a CLI Client For Model Context Protocol Servers](https://neon.tech/blog/building-a-cli-client-for-model-context-protocol-servers).
## Requirements
- ANTHROPIC_API_KEY - Get one from [Anthropic](https://console.anthropic.com/)
- Node.js >= v18.0.0
## How to use
```bash
export ANTHROPIC_API_KEY=your_key_here
npx @neondatabase/mcp-client-cli --server-command="npx" --server-args="-y @neondatabase/mcp-server-neon start <neon-api-key>"
```
## How to develop
1. Clone the repository
2. Setup a `.env` file based on the `.env.example` file
3. Run `npm install`
4. Run `npm run start:mcp-server-neon`
```
--------------------------------------------------------------------------------
/landing/README.md:
--------------------------------------------------------------------------------
```markdown
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
<source media="(prefers-color-scheme: light)" srcset="https://neon.com/brand/neon-logo-light-color.svg">
<img width="250px" alt="Neon Logo fallback" src="https://neon.com/brand/neon-logo-dark-color.svg">
</picture>
# Neon MCP Server
[](https://cursor.com/install-mcp?name=Neon&config=eyJ1cmwiOiJodHRwczovL21jcC5uZW9uLnRlY2gvbWNwIn0%3D)
**Neon MCP Server** is an open-source tool that lets you interact with your Neon Postgres databases in **natural language**.
[](https://www.npmjs.com/package/@neondatabase/mcp-server-neon)
[](https://www.npmjs.com/package/@neondatabase/mcp-server-neon)
[](https://opensource.org/licenses/MIT)
The Model Context Protocol (MCP) is a [new, standardized protocol](https://modelcontextprotocol.io/introduction) designed to manage context between large language models (LLMs) and external systems. This repository offers an installer and an MCP Server for [Neon](https://neon.tech).
Neon's MCP server acts as a bridge between natural language requests and the [Neon API](https://api-docs.neon.tech/reference/getting-started-with-neon-api). Built upon MCP, it translates your requests into the necessary API calls, enabling you to manage tasks such as creating projects and branches, running queries, and performing database migrations seamlessly.
Some of the key features of the Neon MCP server include:
- **Natural language interaction:** Manage Neon databases using intuitive, conversational commands.
- **Simplified database management:** Perform complex actions without writing SQL or directly using the Neon API.
- **Accessibility for non-developers:** Empower users with varying technical backgrounds to interact with Neon databases.
- **Database migration support:** Leverage Neon's branching capabilities for database schema changes initiated via natural language.
For example, in Claude Desktop, or any MCP Client, you can use natural language to accomplish things with Neon, such as:
- `Let's create a new Postgres database, and call it "my-database". Let's then create a table called users with the following columns: id, name, email, and password.`
- `I want to run a migration on my project called "my-project" that alters the users table to add a new column called "created_at".`
- `Can you give me a summary of all of my Neon projects and what data is in each one?`
> [!WARNING]
> **Neon MCP Server Security Considerations**
> The Neon MCP Server grants powerful database management capabilities through natural language requests. **Always review and authorize actions requested by the LLM before execution.** Ensure that only authorized users and applications have access to the Neon MCP Server.
>
> The Neon MCP Server is intended for local development and IDE integrations only. **We do not recommend using the Neon MCP Server in production environments.** It can execute powerful operations that may lead to accidental or unauthorized changes.
>
> For more information, see [MCP security guidance →](https://neon.tech/docs/ai/neon-mcp-server#mcp-security-guidance).
## Setting up Neon MCP Server
You have two options for connecting your MCP client to Neon:
1. **Remote MCP Server (Preview):** Connect to Neon's managed MCP server using OAuth for authentication. This method is more convenient as it eliminates the need to manage API keys. Additionally, you will automatically receive the latest features and improvements as soon as they are released.
2. **Local MCP Server:** Run the Neon MCP server locally on your machine, authenticating with a Neon API key.
## Prerequisites
- An MCP Client application.
- A [Neon account](https://console.neon.tech/signup).
- **Node.js (>= v18.0.0) and npm:** Download from [nodejs.org](https://nodejs.org).
For Local MCP Server setup, you also need a Neon API key. See [Neon API Keys documentation](https://neon.tech/docs/manage/api-keys) for instructions on generating one.
### Option 1. Remote Hosted MCP Server (Preview)
Connect to Neon's managed MCP server using OAuth for authentication. This is the easiest setup, requires no local installation of this server, and doesn't need a Neon API key configured in the client.
- Add the following "Neon" entry to your client's MCP server configuration file (e.g., `mcp.json`, `mcp_config.json`):
```json
{
"mcpServers": {
"Neon": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.neon.tech/mcp"]
}
}
}
```
- Save the configuration file.
- Restart or refresh your MCP client.
- An OAuth window will open in your browser. Follow the prompts to authorize your MCP client to access your Neon account.
> With OAuth base authentication, the MCP server will, by default operate on projects under your personal Neon account. To access or manage projects under organization, you must explicitly provide either the `org_id` or the `project_id` in your prompt to MCP client.
Remote MCP Server also supports authentication using an API key in the `Authorization` header if your client supports it.
#### Quick Setup with API Key
**Don't want to manually create an API key?**
Run [`neonctl@latest init`](https://neon.com/docs/reference/cli-init) to automatically configure Neon's MCP Server with one command:
```bash
npx neonctl@latest init
```
This works with Cursor, VS Code (GitHub Copilot), and Claude Code. It will authenticate via OAuth, create a Neon API key for you, and configure your editor automatically.
#### Manual API Key Setup
For other IDEs or manual configuration, [create a Neon API key](https://console.neon.tech/app/settings?modal=create_api_key) in the Neon Console and use this configuration:
```json
{
"mcpServers": {
"Neon": {
"url": "https://mcp.neon.tech/mcp",
"headers": {
"Authorization": "Bearer <$NEON_API_KEY>"
}
}
}
}
```
> Provider organization's API key to limit access to projects under the organization only.
**Read-Only Mode:** To prevent accidental modifications, enable read-only mode by adding the `x-read-only` header. This restricts the MCP server to only safe, non-destructive operations:
```json
{
"mcpServers": {
"Neon": {
"url": "https://mcp.neon.tech/mcp",
"headers": {
"Authorization": "Bearer <$NEON_API_KEY>",
"x-read-only": "true"
}
}
}
}
```
MCP supports two remote server transports: the deprecated Server-Sent Events (SSE) and the newer, recommended Streamable HTTP. If your LLM client doesn't support Streamable HTTP yet, you can switch the endpoint from `https://mcp.neon.tech/mcp` to `https://mcp.neon.tech/sse` to use SSE instead.
### Option 2. Local MCP Server
Run the Neon MCP server on your local machine with your Neon API key. This method allows you to manage your Neon projects and databases without relying on a remote MCP server.
Add the following JSON configuration within the `mcpServers` section of your client's `mcp_config` file, replacing `<YOUR_NEON_API_KEY>` with your actual Neon API key:
```json
{
"mcpServers": {
"neon": {
"command": "npx",
"args": [
"-y",
"@neondatabase/mcp-server-neon",
"start",
"<YOUR_NEON_API_KEY>"
]
}
}
}
```
### Troubleshooting
If your client does not use `JSON` for configuration of MCP servers (such as older versions of Cursor), you can use the following command when prompted:
```bash
npx -y @neondatabase/mcp-server-neon start <YOUR_NEON_API_KEY>
```
#### Troubleshooting on Windows
If you are using Windows and encounter issues while adding the MCP server, you might need to use the Command Prompt (`cmd`) or Windows Subsystem for Linux (`wsl`) to run the necessary commands. Your configuration setup may resemble the following:
```json
{
"mcpServers": {
"neon": {
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@neondatabase/mcp-server-neon",
"start",
"<YOUR_NEON_API_KEY>"
]
}
}
}
```
```json
{
"mcpServers": {
"neon": {
"command": "wsl",
"args": [
"npx",
"-y",
"@neondatabase/mcp-server-neon",
"start",
"<YOUR_NEON_API_KEY>"
]
}
}
}
```
## Guides
- [Neon MCP Server Guide](https://neon.tech/docs/ai/neon-mcp-server)
- [Connect MCP Clients to Neon](https://neon.tech/docs/ai/connect-mcp-clients-to-neon)
- [Cursor with Neon MCP Server](https://neon.tech/guides/cursor-mcp-neon)
- [Claude Desktop with Neon MCP Server](https://neon.tech/guides/neon-mcp-server)
- [Cline with Neon MCP Server](https://neon.tech/guides/cline-mcp-neon)
- [Windsurf with Neon MCP Server](https://neon.tech/guides/windsurf-mcp-neon)
- [Zed with Neon MCP Server](https://neon.tech/guides/zed-mcp-neon)
# Features
## Supported Tools
The Neon MCP Server provides the following actions, which are exposed as "tools" to MCP Clients. You can use these tools to interact with your Neon projects and databases using natural language commands.
**Project Management:**
- **`list_projects`**: Lists the first 10 Neon projects in your account, providing a summary of each project. If you can't find a specific project, increase the limit by passing a higher value to the `limit` parameter.
- **`list_shared_projects`**: Lists Neon projects shared with the current user. Supports a search parameter and limiting the number of projects returned (default: 10).
- **`describe_project`**: Fetches detailed information about a specific Neon project, including its ID, name, and associated branches and databases.
- **`create_project`**: Creates a new Neon project in your Neon account. A project acts as a container for branches, databases, roles, and computes.
- **`delete_project`**: Deletes an existing Neon project and all its associated resources.
- **`list_organizations`**: Lists all organizations that the current user has access to. Optionally filter by organization name or ID using the search parameter.
**Branch Management:**
- **`create_branch`**: Creates a new branch within a specified Neon project. Leverages [Neon's branching](/docs/introduction/branching) feature for development, testing, or migrations.
- **`delete_branch`**: Deletes an existing branch from a Neon project.
- **`describe_branch`**: Retrieves details about a specific branch, such as its name, ID, and parent branch.
- **`list_branch_computes`**: Lists compute endpoints for a project or specific branch, including compute ID, type, size, last active time, and autoscaling information.
- **`compare_database_schema`**: Shows the schema diff between the child branch and its parent
- **`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.
**SQL Query Execution:**
- **`get_connection_string`**: Returns your database connection string.
- **`run_sql`**: Executes a single SQL query against a specified Neon database. Supports both read and write operations.
- **`run_sql_transaction`**: Executes a series of SQL queries within a single transaction against a Neon database.
- **`get_database_tables`**: Lists all tables within a specified Neon database.
- **`describe_table_schema`**: Retrieves the schema definition of a specific table, detailing columns, data types, and constraints.
**Database Migrations (Schema Changes):**
- **`prepare_database_migration`**: Initiates a database migration process. Critically, it creates a temporary branch to apply and test the migration safely before affecting the main branch.
- **`complete_database_migration`**: Finalizes and applies a prepared database migration to the main branch. This action merges changes from the temporary migration branch and cleans up temporary resources.
**Query Performance Optimization:**
- **`list_slow_queries`**: Identifies performance bottlenecks by finding the slowest queries in a database. Requires the pg_stat_statements extension.
- **`explain_sql_statement`**: Provides detailed execution plans for SQL queries to help identify performance bottlenecks.
- **`prepare_query_tuning`**: Analyzes query performance and suggests optimizations, like index creation. Creates a temporary branch for safely testing these optimizations.
- **`complete_query_tuning`**: Finalizes query tuning by either applying optimizations to the main branch or discarding them. Cleans up the temporary tuning branch.
**Neon Auth:**
- **`provision_neon_auth`**: Provisions Neon Auth for a Neon project. It allows developers to easily set up authentication infrastructure by creating an integration with an Auth provider.
**Search and Discovery:**
- **`search`**: Searches across organizations, projects, and branches matching a query. Returns IDs, titles, and direct links to the Neon Console.
- **`fetch`**: Fetches detailed information about a specific organization, project, or branch using an ID (typically from the search tool).
**Documentation and Resources:**
- **`load_resource`**: Loads comprehensive Neon documentation and usage guidelines, including the "neon-get-started" guide for setup, configuration, and best practices.
## Migrations
Migrations are a way to manage changes to your database schema over time. With the Neon MCP server, LLMs are empowered to do migrations safely with separate "Start" (`prepare_database_migration`) and "Commit" (`complete_database_migration`) commands.
The "Start" command accepts a migration and runs it in a new temporary branch. Upon returning, this command hints to the LLM that it should test the migration on this branch. The LLM can then run the "Commit" command to apply the migration to the original branch.
# Development
## Development with MCP CLI Client
The easiest way to iterate on the MCP Server is using the `mcp-client/`. Learn more in `mcp-client/README.md`.
```bash
npm install
npm run build
npm run watch # You can keep this open.
cd mcp-client/ && NEON_API_KEY=... npm run start:mcp-server-neon
```
## Development with Claude Desktop (Local MCP Server)
```bash
npm install
npm run build
npm run watch # You can keep this open.
node dist/index.js init $NEON_API_KEY
```
Then, **restart Claude** each time you want to test changes.
# Testing
To run the tests you need to setup the `.env` file according to the `.env.example` file.
```bash
npm run test
```
```
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
```markdown
## Agents.md
Use [claude.md](CLAUDE.md) to guide the agents when working with code in this repository.
```
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
```markdown
# CLAUDE.md
This file provides guidance to AI agents when working with code in this repository.
## Project Overview
This is the **Neon MCP Server** - a Model Context Protocol server that bridges natural language requests to the Neon API, enabling LLMs to manage Neon Postgres databases through conversational commands. The project implements both local (stdio) and remote (SSE/Streamable HTTP) MCP server transports with OAuth authentication support.
## Development Commands
### Building and Running
```bash
# Install dependencies
npm install
# Build the project (compiles TypeScript and builds landing page)
npm run build
# Watch mode for development (auto-recompiles on changes)
npm run watch
# Type checking without emitting files
npm run typecheck
# Start local MCP server with API key
node dist/index.js start <NEON_API_KEY>
# Start SSE transport server
node dist/index.js start:sse
```
### Development with MCP CLI Client
The fastest way to iterate on the MCP Server is using the `mcp-client/` CLI:
```bash
npm install
npm run build
npm run watch # Keep this running in one terminal
cd mcp-client/ && NEON_API_KEY=<your-key> npm run start:mcp-server-neon
```
This provides an interactive terminal to test MCP tools without restarting Claude Desktop.
### Testing
```bash
# Run Braintrust evaluations
npm run test
# You must configure .env file with:
# - BRAINTRUST_API_KEY
# - NEON_API_KEY
# - ANTHROPIC_API_KEY
```
### Linting and Formatting
```bash
# Run linting and formatting checks
npm run lint
# Auto-fix linting and formatting issues
npm run lint:fix
# Format code
npm run format
```
### Single Test Development
To develop and test a single tool without running full test suite, modify the test file in `src/tools-evaluations/` and run:
```bash
npm run test
```
## Architecture
### Core Components
1. **MCP Server (`src/server/index.ts`)**
- Creates and configures the MCP server instance
- Registers all tools and resources from centralized definitions
- Implements error handling and observability (Sentry, analytics)
- Each tool call is tracked and wrapped in error handling
2. **Tools System (`src/tools/`)**
- `definitions.ts`: Exports `NEON_TOOLS` array defining all available tools with their schemas
- `tools.ts`: Exports `NEON_HANDLERS` object mapping tool names to handler functions
- `toolsSchema.ts`: Zod schemas for tool input validation
- `handlers/`: Individual tool handler implementations organized by feature
3. **Transport Layers (`src/transports/`)**
- `stdio.ts`: Standard input/output transport for local MCP clients (Claude Desktop, Cursor)
- `sse-express.ts`: Server-Sent Events transport for remote MCP server (deprecated)
- `stream.ts`: Streamable HTTP transport for remote MCP server (recommended)
4. **OAuth System (`src/oauth/`)**
- OAuth 2.0 server implementation for remote MCP authentication
- Integrates with Neon's OAuth provider (UPSTREAM_OAUTH_HOST)
- Token persistence using Keyv with Postgres backend
- Cookie-based client approval tracking
5. **Resources (`src/resources.ts`)**
- MCP resources that provide read-only context (like "getting started" guides)
- Registered alongside tools but don't execute operations
### Key Architectural Patterns
- **Tool Registration Pattern**: All tools are defined in `NEON_TOOLS` array and handlers in `NEON_HANDLERS` object. The server iterates through tools and registers them with their corresponding handlers.
- **Error Handling**: Tools throw errors which are caught by the server wrapper, logged to Sentry, and returned as structured error messages to the LLM.
- **State Management**: Some tools (migrations, query tuning) create temporary branches and maintain state across multiple tool calls. The LLM is prompted to remember branch IDs from previous calls.
- **Analytics & Observability**: Every tool call, resource access, and error is tracked through Segment analytics and Sentry error reporting.
## Adding New Tools
1. Define the tool schema in `src/tools/toolsSchema.ts`:
```typescript
export const myNewToolInputSchema = z.object({
project_id: z.string().describe('The Neon project ID'),
// ... other fields
});
```
2. Add the tool definition to `NEON_TOOLS` array in `src/tools/definitions.ts`:
```typescript
{
name: 'my_new_tool' as const,
description: 'Description of what this tool does',
inputSchema: myNewToolInputSchema,
}
```
3. Create a handler in `src/tools/handlers/my-new-tool.ts`:
```typescript
import { ToolHandler } from '../types.js';
import { myNewToolInputSchema } from '../toolsSchema.js';
export const myNewToolHandler: ToolHandler<'my_new_tool'> = async (
args,
neonClient,
extra,
) => {
// Implementation
return {
content: [
{
type: 'text',
text: 'Result message',
},
],
};
};
```
4. Register the handler in `src/tools/tools.ts`:
```typescript
import { myNewToolHandler } from './handlers/my-new-tool.js';
export const NEON_HANDLERS = {
// ... existing handlers
my_new_tool: myNewToolHandler,
};
```
5. Add evaluations in `src/tools-evaluations/` to test your tool.
## Environment Configuration
See `.env.example` for all configuration options. Key variables:
- `NEON_API_KEY`: Required for local development and testing
- `BRAINTRUST_API_KEY`: Required for running evaluations
- `ANTHROPIC_API_KEY`: Required for running evaluations
- `OAUTH_DATABASE_URL`: Required for remote MCP server with OAuth
- `COOKIE_SECRET`: Required for remote MCP server OAuth flow
- `CLIENT_ID` / `CLIENT_SECRET`: OAuth client credentials
## Project Structure
```
src/
├── index.ts # Entry point, command parser, transport selection
├── server/
│ ├── index.ts # MCP server creation and tool/resource registration
│ └── api.ts # Neon API client factory
├── tools/
│ ├── definitions.ts # Tool definitions (NEON_TOOLS)
│ ├── tools.ts # Tool handlers mapping (NEON_HANDLERS)
│ ├── toolsSchema.ts # Zod schemas for tool inputs
│ └── handlers/ # Individual tool implementations
├── transports/
│ ├── stdio.ts # Local MCP transport
│ ├── sse-express.ts # Remote SSE transport
│ └── stream.ts # Remote Streamable HTTP transport
├── oauth/ # OAuth 2.0 implementation
├── analytics/ # Segment analytics integration
├── sentry/ # Sentry error tracking
└── utils/ # Shared utilities
mcp-client/ # CLI client for testing
landing/ # Next.js landing page
```
## Important Notes
- **TypeScript Configuration**: Uses ES2022 with Node16 module resolution. All imports must use `.js` extensions (not `.ts`) due to ESM requirements.
- **Building**: The build process includes chmod operations to make `dist/index.js` executable, exports tool definitions to `landing/tools.json`, and builds the landing page.
- **Logger Behavior**: In stdio mode, the logger is silenced to prevent stderr pollution. In SSE mode, logging is active.
- **Migration Pattern**: Tools like `prepare_database_migration` and `prepare_query_tuning` create temporary branches. The LLM must remember these branch IDs to pass to subsequent `complete_*` tools.
- **Neon API Client**: Created using `@neondatabase/api-client` package. All tool handlers receive a pre-configured `neonClient` instance.
## Claude Code Review Workflow
This repository uses an enhanced Claude Code Review workflow that provides inline feedback on pull requests.
### What Gets Reviewed
- Architecture and design patterns (tool registration, handler typing)
- Security vulnerabilities (SQL injection, secrets, input validation)
- Logic bugs (error handling, state management, edge cases)
- Performance issues (N+1 queries, inefficient API usage)
- Testing gaps (missing evaluations, uncovered scenarios)
- MCP-specific patterns (analytics tracking, error handling, Sentry capture)
### What's Automated (Not Reviewed by Claude)
- Linting: `bun run lint` (checked by pr.yml)
- Building: `bun run build` (checked by pr.yml)
- Formatting: Automated formatting checks
### Review Process
1. Workflow triggers automatically on PR open
2. Claude analyzes changes with full project context
3. Inline comments posted on significant issues
4. Summary comment provides overview and statistics
### Inline Comment Format
- **Severity**: 🔴 Critical | 🟡 Important | 🔵 Consider
- **Category**: [Security/Logic/Performance/Architecture/Testing/MCP]
- **Description**: Clear explanation with context
- **Fix**: Actionable code example or reference
Example:
```
🔴 **[Security]**: SQL injection vulnerability - user input concatenated directly into SQL.
**Fix:** Use parameterized queries:
const result = await query('SELECT * FROM users WHERE name = $1', [userName]);
```
### Triggering Reviews
- **Automatic**: Opens when PR is created
- **Manual**: Run workflow via GitHub Actions with PR number
- **Security**: Only OWNER/MEMBER/COLLABORATOR PRs (blocks external)
## Testing Strategy
Tests use Braintrust for LLM-based evaluations. Each test:
1. Defines a task/prompt
2. Executes it against the MCP server
3. Evaluates the result using Braintrust scoring functions
This validates that tools work correctly with realistic LLM interactions.
```
--------------------------------------------------------------------------------
/src/types/helpers.ts:
--------------------------------------------------------------------------------
```typescript
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
```
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
```json
{
"extends": "./tsconfig.json",
"include": ["src/tools-evaluations/**/*"]
}
```
--------------------------------------------------------------------------------
/landing/postcss.config.mjs:
--------------------------------------------------------------------------------
```
const config = {
plugins: ['@tailwindcss/postcss'],
};
export default config;
```
--------------------------------------------------------------------------------
/landing/next.config.ts:
--------------------------------------------------------------------------------
```typescript
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',
};
export default nextConfig;
```
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
```typescript
export { NEON_TOOLS } from './definitions.js';
export { NEON_HANDLERS } from './tools.js';
export { ToolHandlers, ToolHandlerExtended } from './types.js';
```
--------------------------------------------------------------------------------
/landing/lib/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
--------------------------------------------------------------------------------
/PUBLISH.md:
--------------------------------------------------------------------------------
```markdown
## Publish
### New release
```bash
npm run build
npm version patch|minor|major
npm publish
```
### New Beta Release
```bash
npm run build
npm version prerelease --preid=beta
npm publish --tag beta
```
### Promote beta to release
```bash
npm version patch
npm publish
```
```
--------------------------------------------------------------------------------
/mcp-client/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
```
--------------------------------------------------------------------------------
/mcp-client/src/neon-cli-client.ts:
--------------------------------------------------------------------------------
```typescript
import { MCPClientCLI } from './cli-client.js';
import path from 'path';
import dotenv from 'dotenv';
dotenv.config({
path: path.resolve(__dirname, '../.env'),
});
const cli = new MCPClientCLI({
command: path.resolve(__dirname, '../../dist/index.js'), // Use __dirname for relative path
args: ['start', process.env.NEON_API_KEY!],
});
cli.start();
```
--------------------------------------------------------------------------------
/src/types/express.d.ts:
--------------------------------------------------------------------------------
```typescript
import { AuthContext } from './auth.js';
// to make the file a module and avoid the TypeScript error
export {};
// Extends the Express Request interface to add the auth context
declare global {
namespace Express {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export interface Request {
auth?: AuthContext;
}
}
}
```
--------------------------------------------------------------------------------
/src/types/auth.ts:
--------------------------------------------------------------------------------
```typescript
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
export type AuthContext = {
extra: {
readOnly?: boolean;
account: {
id: string;
name: string;
email?: string;
isOrg?: boolean; // For STDIO mode with org API key
};
client?: {
id: string;
name: string;
};
[key: string]: unknown;
};
} & AuthInfo;
```
--------------------------------------------------------------------------------
/src/sentry/instrument.ts:
--------------------------------------------------------------------------------
```typescript
import { init } from '@sentry/node';
import { SENTRY_DSN } from '../constants.js';
import { getPackageJson } from '../server/api.js';
init({
dsn: SENTRY_DSN,
environment: process.env.NODE_ENV,
release: getPackageJson().version,
tracesSampleRate: 1.0,
// Setting this option to true will send default PII data to Sentry.
// For example, automatic IP address collection on events
sendDefaultPii: true,
});
```
--------------------------------------------------------------------------------
/landing/components/ExternalLink.tsx:
--------------------------------------------------------------------------------
```typescript
import { ReactNode } from 'react';
import { ExternalIcon } from '@/components/ExternalIcon';
type ExternalLinkProps = { href: string; children?: ReactNode };
export const ExternalLink = ({ href, children }: ExternalLinkProps) => (
<a
className="inline-flex items-center gap-1 w-fit external-link"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
<ExternalIcon />
</a>
);
```
--------------------------------------------------------------------------------
/landing/components.json:
--------------------------------------------------------------------------------
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "src/tools-evaluations/**/*"]
}
```
--------------------------------------------------------------------------------
/src/tools/handlers/decribe-project.ts:
--------------------------------------------------------------------------------
```typescript
import { Api } from '@neondatabase/api-client';
async function handleDescribeProject(
projectId: string,
neonClient: Api<unknown>,
) {
const { data: branchesData } = await neonClient.listProjectBranches({
projectId: projectId,
});
const { data: projectData } = await neonClient.getProject(projectId);
return {
branches: branchesData.branches,
project: projectData.project,
};
}
export { handleDescribeProject };
```
--------------------------------------------------------------------------------
/src/transports/stdio.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
/**
* Start the server using stdio transport.
* This allows the server to communicate via standard input/output streams.
*/
export const startStdio = async (server: McpServer) => {
const transport = new StdioServerTransport();
await server.connect(transport);
};
```
--------------------------------------------------------------------------------
/src/types/context.ts:
--------------------------------------------------------------------------------
```typescript
import { Environment } from '../constants.js';
import { AuthContext } from './auth.js';
export type AppContext = {
name: string;
transport: 'sse' | 'stdio' | 'stream';
environment: Environment;
version: string;
};
export type ServerContext = {
apiKey: string;
client?: AuthContext['extra']['client'];
account: AuthContext['extra']['account'];
app: AppContext;
readOnly?: AuthContext['extra']['readOnly'];
userAgent?: string;
};
```
--------------------------------------------------------------------------------
/src/utils/polyfills.ts:
--------------------------------------------------------------------------------
```typescript
import nodeFetch, {
Headers as NodeHeaders,
Request as NodeRequest,
Response as NodeResponse,
} from 'node-fetch';
// Use different names to avoid conflicts
declare global {
function fetch(
url: string | Request | URL,
init?: RequestInit,
): Promise<Response>;
}
if (!global.fetch) {
global.fetch = nodeFetch as any;
global.Headers = NodeHeaders as any;
global.Request = NodeRequest as any;
global.Response = NodeResponse as any;
}
```
--------------------------------------------------------------------------------
/landing/eslint.config.mjs:
--------------------------------------------------------------------------------
```
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
rules: {
'react/no-unescaped-entities': 'off',
},
},
];
export default eslintConfig;
```
--------------------------------------------------------------------------------
/landing/components/ExternalIcon.tsx:
--------------------------------------------------------------------------------
```typescript
export const ExternalIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
fill="none"
viewBox="0 0 12 12"
className="-mb-px shrink-0"
>
<rect
width="12"
height="12"
fill="currentColor"
opacity="0.2"
rx="2"
></rect>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M8.499 7.616v-4.12h-4.12M8.25 3.75 3.5 8.5"
></path>
</svg>
);
```
--------------------------------------------------------------------------------
/src/sentry/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { setTags, setUser } from '@sentry/node';
import { ServerContext } from '../types/context.js';
export const setSentryTags = (context: ServerContext) => {
setUser({
id: context.account.id,
});
setTags({
'app.name': context.app.name,
'app.version': context.app.version,
'app.transport': context.app.transport,
'app.environment': context.app.environment,
});
if (context.client) {
setTags({
'client.id': context.client.id,
'client.name': context.client.name,
});
}
};
```
--------------------------------------------------------------------------------
/src/server/api.ts:
--------------------------------------------------------------------------------
```typescript
import { createApiClient } from '@neondatabase/api-client';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import { NEON_API_HOST } from '../constants.js';
export const getPackageJson = () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
return JSON.parse(
fs.readFileSync(path.join(__dirname, '../..', 'package.json'), 'utf8'),
);
};
export const createNeonClient = (apiKey: string) =>
createApiClient({
apiKey,
baseURL: NEON_API_HOST,
headers: {
'User-Agent': `mcp-server-neon/${getPackageJson().version}`,
},
});
```
--------------------------------------------------------------------------------
/landing/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2018",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"../public/types/**/*.ts"
],
"exclude": ["node_modules"]
}
```
--------------------------------------------------------------------------------
/src/tools/handlers/list-orgs.ts:
--------------------------------------------------------------------------------
```typescript
import { Api, Organization } from '@neondatabase/api-client';
import { ToolHandlerExtraParams } from '../types.js';
import { filterOrganizations } from '../utils.js';
export async function handleListOrganizations(
neonClient: Api<unknown>,
account: ToolHandlerExtraParams['account'],
search?: string,
): Promise<Organization[]> {
if (account.isOrg) {
const orgId = account.id;
const { data } = await neonClient.getOrganization(orgId);
return filterOrganizations([data], search);
}
const { data: response } = await neonClient.getCurrentUserOrganizations();
const organizations = response.organizations || [];
return filterOrganizations(organizations, search);
}
```
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
```yaml
name: Lint and Build
on:
pull_request:
branches:
- main
jobs:
lint-and-build:
runs-on:
group: neondatabase-protected-runner-group
labels: linux-ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
- name: Install root dependencies
run: bun install --frozen-lockfile
- name: Install landing dependencies
working-directory: landing
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Build
run: bun run build
```
--------------------------------------------------------------------------------
/landing/icons/github.svg:
--------------------------------------------------------------------------------
```
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
```
--------------------------------------------------------------------------------
/mcp-client/src/logger.ts:
--------------------------------------------------------------------------------
```typescript
import chalk from 'chalk';
type LoggingMode = 'verbose' | 'error' | 'none';
export type LoggerOptions = {
mode: LoggingMode;
};
export const consoleStyles = {
prompt: chalk.green('You: '),
assistant: chalk.blue('Claude: '),
tool: {
name: chalk.cyan.bold,
args: chalk.yellow,
bracket: chalk.dim,
},
error: chalk.red,
info: chalk.blue,
success: chalk.green,
warning: chalk.yellow,
separator: chalk.gray('─'.repeat(50)),
default: chalk,
};
export class Logger {
private mode: LoggingMode = 'verbose';
constructor({ mode }: LoggerOptions) {
this.mode = mode;
}
log(
message: string,
options?: { type?: 'info' | 'error' | 'success' | 'warning' },
) {
if (this.mode === 'none') return;
if (this.mode === 'error' && options?.type !== 'error') return;
process.stdout.write(consoleStyles[options?.type ?? 'default'](message));
}
}
```
--------------------------------------------------------------------------------
/landing/app/layout.tsx:
--------------------------------------------------------------------------------
```typescript
import type { ReactNode } from 'react';
import type { Metadata } from 'next';
import Head from 'next/head';
import { Geist, Geist_Mono } from 'next/font/google';
import { ThemeProvider } from '@/components/ThemeProvider';
import './globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Neon MCP',
description: 'Learn how to use Neon MCP',
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<Head>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
</Head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
```
--------------------------------------------------------------------------------
/src/utils/client-application.ts:
--------------------------------------------------------------------------------
```typescript
type KnownClientApplication =
| 'cursor'
| 'claude-code'
| 'claude-desktop'
| 'v0'
| 'vscode';
export type ClientApplication = KnownClientApplication | 'unknown';
/**
* Detects the client application type from the MCP client name or User-Agent.
* @param clientName - The name of the MCP client
* @returns The detected client application type
*/
export function detectClientApplication(
clientName?: string,
): ClientApplication {
if (!clientName) return 'unknown';
const normalized = clientName.toLowerCase();
// Known clients
if (normalized.includes('cursor')) return 'cursor';
if (normalized.includes('claude-code')) return 'claude-code';
if (
normalized.includes('claude-user') ||
normalized.includes('claude desktop')
)
return 'claude-desktop';
if (normalized.includes('v0bot')) return 'v0';
if (normalized.includes('visual studio code')) return 'vscode';
return 'unknown';
}
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
import winston from 'winston';
import morgan from 'morgan';
import { Request, Response, NextFunction } from 'express';
const loggerFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.simple(),
winston.format.errors({ stack: true }),
winston.format.align(),
winston.format.colorize(),
);
// Configure Winston logger
export const logger = winston.createLogger({
level: 'info',
format: loggerFormat,
transports: [
new winston.transports.Console({
format: loggerFormat,
}),
],
});
// Configure Morgan for HTTP request logging
export const morganConfig = morgan('combined', {
stream: {
write: (message: string) => logger.info(message.trim()),
},
});
// Configure error handling middleware
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction,
) => {
logger.error('Error:', { error: err.message, stack: err.stack });
next(err);
};
```
--------------------------------------------------------------------------------
/landing/components/CopyableUrl.tsx:
--------------------------------------------------------------------------------
```typescript
'use client';
import { useState } from 'react';
export const CopyableUrl = ({ url }: { url: string }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className="my-2 relative">
<div className="monospaced whitespace-pre-wrap bg-secondary px-3 py-2 border-l-4 border-primary/20 rounded-r-md flex items-center justify-between group">
<span className="text-sm">{url}</span>
<button
onClick={handleCopy}
className="ml-3 px-2 py-1 text-xs bg-primary/10 hover:bg-primary/20 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Copy to clipboard"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
);
};
```
--------------------------------------------------------------------------------
/landing/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "neon-mcp-landing",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-slot": "^1.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lodash": "^4.17.21",
"lucide-react": "^0.511.0",
"next": "15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-syntax-highlighter": "^15.6.1",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/lodash": "^4.17.16",
"@types/node": "^20",
"@types/react": "19.1.8",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.0",
"typescript": "^5"
}
}
```
--------------------------------------------------------------------------------
/landing/components/CodeSnippet.tsx:
--------------------------------------------------------------------------------
```typescript
'use client';
import { Suspense } from 'react';
import dynamic from 'next/dynamic';
import {
docco,
stackoverflowDark,
} from 'react-syntax-highlighter/dist/esm/styles/hljs';
import { useTheme } from '@/components/ThemeProvider';
const SyntaxHighlighter = dynamic(
() => import('react-syntax-highlighter').then((module) => module.default),
{
ssr: false,
},
);
type Props = {
type?: string;
children: string;
};
export const CodeSnippet = ({ type, children }: Props) => {
const theme = useTheme();
return (
<div className="my-2">
<Suspense
fallback={
<div className="monospaced whitespace-pre-wrap bg-secondary px-2 py-[0.5em] border-l-4">
{children}
</div>
}
>
<SyntaxHighlighter
language={type}
wrapLongLines
style={theme === 'light' ? docco : stackoverflowDark}
>
{children}
</SyntaxHighlighter>
</Suspense>
</div>
);
};
```
--------------------------------------------------------------------------------
/src/tools/handlers/urls.ts:
--------------------------------------------------------------------------------
```typescript
import { NEON_CONSOLE_HOST } from '../../constants.js';
import { NotFoundError } from '../../server/errors.js';
export enum CONSOLE_URLS {
ORGANIZATION = '/app/:orgId/projects',
PROJECT = '/app/projects/:projectId',
PROJECT_BRANCH = '/app/projects/:projectId/branches/:branchId',
}
type ExtractPathParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [k in Param | keyof ExtractPathParams<`/${Rest}`>]: string | number }
: T extends `${string}:${infer Param}`
? Record<Param, string | number>
: Record<string, never>;
export function generateConsoleUrl<T extends CONSOLE_URLS>(
url: T,
params: ExtractPathParams<T>,
): string {
const link = url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
if ((params as any)[key] === undefined) {
throw new NotFoundError(`Missing parameter '${key}' for url '${url}'`);
}
return encodeURIComponent(String((params as any)[key]));
});
return new URL(link, NEON_CONSOLE_HOST).toString();
}
```
--------------------------------------------------------------------------------
/mcp-client/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@neondatabase/mcp-client-cli",
"version": "0.1.1",
"description": "MCP client CLI for interacting with a MCP server",
"license": "MIT",
"author": "Neon, Inc. (https://neon.tech/)",
"homepage": "https://github.com/neondatabase/mcp-server-neon/",
"bugs": "https://github.com/neondatabase/mcp-server-neon/issues",
"type": "module",
"access": "public",
"bin": {
"mcp-client": "./dist/bin.js"
},
"files": [
"dist"
],
"scripts": {
"start:mcp-server-neon": "cd .. && bun run build && cd - && bun ./src/neon-cli-client.ts",
"build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"",
"prepare": "npm run build",
"watch": "tsc-watch --onSuccess \"chmod 755 dist/index.js\"",
"format": "prettier --write ."
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@modelcontextprotocol/sdk": "^1.0.3",
"chalk": "^5.3.0",
"dotenv": "16.4.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"bun": "^1.1.38",
"prettier": "^3.4.1",
"tsc-watch": "^6.2.1",
"typescript": "^5.7.2"
}
}
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
# Use the imbios/bun-node image as the base image with Node and Bun
# Keep bun and node version in sync with package.json
FROM imbios/bun-node:1.1.38-18-alpine AS builder
# Set the working directory in the container
WORKDIR /app
# Copy package.json and package-lock.json
COPY package.json package-lock.json ./
# Copy the entire project to the working directory
COPY . .
# Install the dependencies and devDependencies
RUN npm install
# Build the project
RUN npm run build
# Use a smaller base image for the final image
FROM node:18-alpine AS release
# Set the working directory
WORKDIR /app
# Copy only the necessary files from the builder stage
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/package-lock.json /app/package-lock.json
# Install only production dependencies
RUN npm ci --omit=dev
# Define environment variables
ENV NODE_ENV=production
# Specify the command to run the MCP server
ENTRYPOINT ["node", "dist/index.js", "start", "$NEON_API_KEY"]
```
--------------------------------------------------------------------------------
/src/tools-evaluations/evalUtils.ts:
--------------------------------------------------------------------------------
```typescript
import { createApiClient } from '@neondatabase/api-client';
import path from 'path';
import { MCPClient } from '../../mcp-client/src/index.js';
export async function deleteNonDefaultBranches(projectId: string) {
const neonClient = createApiClient({
apiKey: process.env.NEON_API_KEY!,
});
try {
const allBranches = await neonClient.listProjectBranches({
projectId: projectId,
});
const branchesToDelete = allBranches.data.branches.filter(
(b) => !b.default,
);
await Promise.all(
branchesToDelete.map((b) =>
neonClient.deleteProjectBranch(b.project_id, b.id),
),
);
} catch (e) {
console.error(e);
}
}
export async function evaluateTask(input: string) {
const client = new MCPClient({
command: path.resolve(__dirname, '../../dist/index.js'),
args: ['start', process.env.NEON_API_KEY!],
loggerOptions: {
mode: 'error',
},
});
await client.start();
const response = await client.processQuery(input);
await client.stop();
if (!response) {
throw new Error('No response from MCP Client');
}
return response;
}
```
--------------------------------------------------------------------------------
/mcp-client/src/bin.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { parseArgs } from 'node:util';
import { MCPClientCLI } from './cli-client.js';
function checkRequiredEnvVars() {
if (!process.env.ANTHROPIC_API_KEY) {
console.error(
'\x1b[31mError: ANTHROPIC_API_KEY environment variable is required\x1b[0m',
);
console.error('Please set it before running the CLI:');
console.error(' export ANTHROPIC_API_KEY=your_key_here');
process.exit(1);
}
}
async function main() {
try {
checkRequiredEnvVars();
const args = parseArgs({
options: {
'server-command': { type: 'string' },
'server-args': { type: 'string' },
},
allowPositionals: true,
});
const serverCommand = args.values['server-command'];
const serverArgs = args.values['server-args']?.split(' ') || [];
if (!serverCommand) {
console.error('Error: --server-command is required');
process.exit(1);
}
const cli = new MCPClientCLI({
command: serverCommand,
args: serverArgs,
});
await cli.start();
} catch (error) {
console.error('Failed to start CLI:', error);
process.exit(1);
}
}
main();
```
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
```typescript
import { config } from 'dotenv';
config();
export type Environment = 'development' | 'production' | 'preview';
export const NEON_DEFAULT_DATABASE_NAME = 'neondb';
export const NODE_ENV = (process.env.NODE_ENV ?? 'production') as Environment;
export const IS_DEV = NODE_ENV === 'development';
export const SERVER_PORT = 3001;
export const SERVER_HOST =
process.env.SERVER_HOST ?? `http://localhost:${SERVER_PORT}`;
export const CLIENT_ID = process.env.CLIENT_ID ?? '';
export const CLIENT_SECRET = process.env.CLIENT_SECRET ?? '';
export const UPSTREAM_OAUTH_HOST =
process.env.UPSTREAM_OAUTH_HOST ?? 'https://oauth2.neon.tech';
export const REDIRECT_URI = `${SERVER_HOST}/callback`;
export const NEON_API_HOST =
process.env.NEON_API_HOST ?? 'https://console.neon.tech/api/v2';
export const COOKIE_SECRET = process.env.COOKIE_SECRET ?? '';
export const ANALYTICS_WRITE_KEY =
process.env.ANALYTICS_WRITE_KEY ?? 'gFVzt8ozOp6AZRXoD0g0Lv6UQ6aaoS7O';
export const SENTRY_DSN =
process.env.SENTRY_DSN ??
'https://[email protected]/4509328350380033';
export const NEON_CONSOLE_HOST = NEON_API_HOST.replace(/\/api\/v2$/, '');
```
--------------------------------------------------------------------------------
/landing/components/ThemeProvider.tsx:
--------------------------------------------------------------------------------
```typescript
'use client';
import {
createContext,
ReactNode,
useContext,
useLayoutEffect,
useState,
} from 'react';
export type Theme = 'light' | 'dark';
type ThemeProviderState = {
theme: Theme;
};
const ThemeContext = createContext<ThemeProviderState>({
theme: 'light',
});
export const ThemeProvider = ({ children }: { children?: ReactNode }) => {
const [themeState, setThemeState] = useState<ThemeProviderState>({
theme: 'light',
});
useLayoutEffect(() => {
const match = window.matchMedia('(prefers-color-scheme:dark)');
function onChange(event: { matches: boolean }) {
setThemeState((themeState) => {
const targetTheme = event.matches ? 'dark' : 'light';
if (themeState.theme === targetTheme) {
return themeState;
}
return {
...themeState,
theme: targetTheme,
};
});
}
onChange(match);
match.addEventListener('change', onChange);
return () => {
match.removeEventListener('change', onChange);
};
}, []);
return <ThemeContext value={themeState}>{children}</ThemeContext>;
};
export function useTheme(): Theme {
return useContext(ThemeContext).theme;
}
```
--------------------------------------------------------------------------------
/src/tools/state.ts:
--------------------------------------------------------------------------------
```typescript
import { Branch } from '@neondatabase/api-client';
type MigrationId = string;
export type MigrationDetails = {
migrationSql: string;
databaseName: string;
appliedBranch: Branch;
roleName?: string;
};
type TuningId = string;
export type TuningDetails = {
sql: string;
databaseName: string;
tuningBranch: Branch;
roleName?: string;
originalPlan?: any;
suggestedChanges?: string[];
improvedPlan?: any;
};
const migrationsState = new Map<MigrationId, MigrationDetails>();
const tuningState = new Map<TuningId, TuningDetails>();
export function getMigrationFromMemory(migrationId: string) {
return migrationsState.get(migrationId);
}
export function persistMigrationToMemory(
migrationId: string,
migrationDetails: MigrationDetails,
) {
migrationsState.set(migrationId, migrationDetails);
}
export function getTuningFromMemory(tuningId: string) {
return tuningState.get(tuningId);
}
export function persistTuningToMemory(
tuningId: string,
tuningDetails: TuningDetails,
) {
tuningState.set(tuningId, tuningDetails);
}
export function updateTuningInMemory(
tuningId: string,
updates: Partial<TuningDetails>,
) {
const existing = tuningState.get(tuningId);
if (existing) {
tuningState.set(tuningId, { ...existing, ...updates });
}
}
```
--------------------------------------------------------------------------------
/src/tools/types.ts:
--------------------------------------------------------------------------------
```typescript
import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Api } from '@neondatabase/api-client';
import { NEON_TOOLS } from './definitions.js';
import { AuthContext } from '../types/auth.js';
import { ClientApplication } from '../utils/client-application.js';
// Extract the tool names as a union type
type NeonToolName = (typeof NEON_TOOLS)[number]['name'];
export type ToolParams<T extends NeonToolName> = Extract<
(typeof NEON_TOOLS)[number],
{ name: T }
>['inputSchema'];
export type ToolHandler<T extends NeonToolName> = ToolCallback<{
params: ToolParams<T>;
}>;
export type ToolHandlerExtraParams = Parameters<
ToolHandler<NeonToolName>
>['1'] & {
account: AuthContext['extra']['account'];
readOnly?: AuthContext['extra']['readOnly'];
/** Detected client application type (e.g., 'cursor', 'claude', 'other') */
clientApplication: ClientApplication;
};
export type ToolHandlerExtended<T extends NeonToolName> = (
...args: [
args: Parameters<ToolHandler<T>>['0'],
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
]
) => ReturnType<ToolHandler<T>>;
// Create a type for the tool handlers that directly maps each tool to its appropriate input schema
export type ToolHandlers = {
[K in NeonToolName]: ToolHandlerExtended<K>;
};
```
--------------------------------------------------------------------------------
/scripts/before-publish.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
function checkMainBranch(version) {
// Skip main branch check for beta versions
if (version.includes('beta')) {
return;
}
try {
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf8',
}).trim();
if (currentBranch !== 'main') {
console.error(
'\x1b[31mError: Publishing stable versions is only allowed from the main branch\x1b[0m',
);
console.error(`Current branch: ${currentBranch}`);
process.exit(1);
}
} catch (error) {
console.error('Error: Git repository not found');
process.exit(1);
}
}
function checkChangelog() {
const changelogPath = path.join(__dirname, '../CHANGELOG.md');
const packagePath = path.join(__dirname, '../package.json');
const { version } = require(packagePath);
try {
const changelog = fs.readFileSync(changelogPath, 'utf8');
if (!changelog.includes(version)) {
console.error(
`\x1b[31mError: Version ${version} not found in CHANGELOG.md\x1b[0m`,
);
console.error('Please update the changelog before publishing');
process.exit(1);
}
return version;
} catch (err) {
console.error('\x1b[31mError: CHANGELOG.md not found\x1b[0m');
process.exit(1);
}
}
function beforePublish() {
const version = checkChangelog();
checkMainBranch(version);
}
beforePublish();
```
--------------------------------------------------------------------------------
/remote.Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Use the imbios/bun-node image as the base image with Node and Bun
# Keep bun and node version in sync with package.json
ARG NODE_VERSION=22.0.0
ARG BUN_VERSION=1.2.13
FROM imbios/bun-node:1.2.13-22-slim AS base
# Set the working directory in the container
WORKDIR /app
# Set production environment
ENV NODE_ENV="production"
# Throw-away build stage to reduce size of final image
FROM base As builder
# Install packages needed to build node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3
# Copy package.json and package-lock.json
COPY package.json package-lock.json ./
# Install the root dependencies and devDependencies
RUN npm ci --include=dev
# Copy landing's package.json and package-lock.json
COPY landing/package.json landing/package-lock.json ./landing/
# Install the landing dependencies and devDependencies
RUN cd landing/ && npm ci --include=dev
# Copy the entire project to the working directory
COPY . .
# Build the project
RUN npm run build
# Remove development dependencies
RUN npm prune --omit=dev
# We don't need Next.js dependencies since landing is statically exported during build step.
RUN rm -rf landing/node_modules
# Final stage for app image
FROM base
# Copy built application
COPY --from=builder /app /app
# Define environment variables
ENV NODE_ENV=production
EXPOSE 3001
# Specify the command to run the MCP server
CMD ["node", "dist/index.js", "start:sse"]
```
--------------------------------------------------------------------------------
/.github/workflows/koyeb-prod.yml:
--------------------------------------------------------------------------------
```yaml
name: Build and deploy backend to production
on:
workflow_dispatch:
push:
branches:
- 'main'
jobs:
deploy:
concurrency:
group: '${{ github.ref_name }}'
cancel-in-progress: true
runs-on:
group: neondatabase-protected-runner-group
labels: linux-ubuntu-latest
# Only main branch is allowed to deploy to production
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install and configure the Koyeb CLI
uses: koyeb-community/koyeb-actions@v2
with:
api_token: '${{ secrets.KOYEB_PROD_TOKEN }}'
- name: Build and deploy to Koyeb production
run: |
koyeb deploy . platform-${{ github.ref_name }}/main \
--instance-type medium \
--region was \
--archive-builder docker \
--archive-docker-dockerfile remote.Dockerfile \
--privileged \
--type web \
--port 3001:http \
--route /:3001 \
--wait \
--env CLIENT_ID=${{secrets.PROD_CLIENT_ID}} \
--env CLIENT_SECRET=${{secrets.PROD_CLIENT_SECRET}} \
--env OAUTH_DATABASE_URL=${{secrets.PROD_OAUTH_DATABASE_URL}} \
--env SERVER_HOST=${{vars.PROD_SERVER_HOST}} \
--env NEON_API_HOST=${{vars.PROD_NEON_API_HOST}} \
--env UPSTREAM_OAUTH_HOST=${{vars.PROD_OAUTH_HOST}} \
--env COOKIE_SECRET=${{secrets.COOKIE_SECRET}} \
```
--------------------------------------------------------------------------------
/landing/components/ui/alert.tsx:
--------------------------------------------------------------------------------
```typescript
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-3 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
important:
'border-important-notes/50 text-important-notes dark:border-important-notes [&>svg]:text-important-notes',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
export function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
export function AlertTitle({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'py-1 mb-1 font-medium leading-none tracking-tight',
className,
)}
{...props}
/>
);
}
export function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
);
}
```
--------------------------------------------------------------------------------
/landing/components/Header.tsx:
--------------------------------------------------------------------------------
```typescript
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import githubSvg from '@/icons/github.svg';
import neonSvg from '@/icons/neon.svg';
type HeaderProps = {
packageVersion: number;
};
export const Header = ({ packageVersion }: HeaderProps) => (
<header className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Image src={neonSvg} width={30} height={30} alt="Neon logo" />
<div className="flex items-baseline gap-2">
<h1 className="text-3xl font-bold whitespace-nowrap">Neon MCP</h1>{' '}
version: {packageVersion}
</div>
</div>
<div className="flex items-center gap-2">
<a
href="https://cursor.com/en-US/install-mcp?name=Neon%20MCP%20Server&config=eyJ1cmwiOiJodHRwOi8vbWNwLm5lb24udGVjaC9tY3AifQ%3D%3D"
target="_blank"
rel="noopener noreferrer"
>
<Image
alt="Add to Cursor"
src="https://cursor.com/deeplink/mcp-install-light.svg"
className="invert dark:invert-0"
width={126}
height={32}
/>
</a>
<Button asChild size="xs">
<a
href="https://github.com/neondatabase-labs/mcp-server-neon?tab=readme-ov-file"
target="_blank"
rel="noopener noreferrer"
>
<Image
alt=""
src={githubSvg}
className="invert dark:invert-0"
width={16}
height={16}
/>{' '}
Github
</a>
</Button>
</div>
</header>
);
```
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
```javascript
import js from '@eslint/js';
import ts from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier';
// @ts-check
export default ts.config(
{
files: ['**/*.ts', '**/*.cts', '**.*.mts'],
ignores: [
'**/*.js',
'**/*.gen.ts',
// see https://eslint.org/docs/latest/use/configure/configuration-files#globally-ignoring-files-with-ignores
'src/tools-evaluations/**/*',
'landing/**/*',
],
rules: {
'no-console': 'off',
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/restrict-template-expressions': [
'error',
{
allowAny: true,
allowBoolean: true,
allowNullish: true,
allowNumber: true,
allowRegExp: true,
},
],
},
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
extends: [
js.configs.recommended,
...ts.configs.strictTypeChecked,
...ts.configs.stylisticTypeChecked,
],
},
prettierConfig,
);
```
--------------------------------------------------------------------------------
/src/tools/handlers/list-projects.ts:
--------------------------------------------------------------------------------
```typescript
import { Api, ListProjectsParams } from '@neondatabase/api-client';
import { ToolHandlerExtraParams } from '../types.js';
import { getOrgByOrgIdOrDefault } from '../utils.js';
import { handleListOrganizations } from './list-orgs.js';
export async function handleListProjects(
params: ListProjectsParams,
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
) {
const organization = await getOrgByOrgIdOrDefault(params, neonClient, extra);
const response = await neonClient.listProjects({
...params,
org_id: organization?.id,
});
if (response.status !== 200) {
throw new Error(`Failed to list projects: ${response.statusText}`);
}
let projects = response.data.projects;
// If search is provided and no org_id specified, and no projects found in personal account,
// search across all user organizations
if (params.search && !params.org_id && projects.length === 0) {
const organizations = await handleListOrganizations(
neonClient,
extra.account,
);
// Search projects across all organizations
const allProjects = [];
for (const org of organizations) {
// Skip the default organization
if (organization?.id === org.id) {
continue;
}
const orgResponse = await neonClient.listProjects({
...params,
org_id: org.id,
});
if (orgResponse.status === 200) {
allProjects.push(...orgResponse.data.projects);
}
}
// If we found projects in other organizations, return them
if (allProjects.length > 0) {
projects = allProjects;
}
}
return projects;
}
```
--------------------------------------------------------------------------------
/src/tools/handlers/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { Api } from '@neondatabase/api-client';
import { handleListProjects } from './list-projects.js';
import { ToolHandlerExtraParams } from '../types.js';
import { NotFoundError } from '../../server/errors.js';
import { looksLikeBranchId, looksLikeProjectId } from '../utils.js';
type MCPOrgId = `org:${string}`;
type MCPProjectId = `project:${string}`;
type MCPBranchId = `branch:${string}/${string}`; // projectId/branchId
export type SearchResultId = MCPOrgId | MCPProjectId | MCPBranchId;
export const isOrgId = (id: string): id is MCPOrgId => {
return id.startsWith('org:') || id.startsWith('org-');
};
export const isProjectId = (id: string): id is MCPProjectId => {
return id.startsWith('project:') || looksLikeProjectId(id);
};
export const isBranchId = (id: string): id is MCPProjectId => {
return id.startsWith('branch:') || looksLikeBranchId(id);
};
export async function getOnlyProject(
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
) {
const projects = await handleListProjects({}, neonClient, extra);
if (projects.length === 1) {
return projects[0];
}
throw new NotFoundError(
'Please provide a project ID or ensure you have only one project in your account.',
);
}
export const getDefaultBranch = async (
projectId: string,
neonClient: Api<unknown>,
) => {
const branches = await neonClient.listProjectBranches({
projectId,
});
const defaultBranch = branches.data.branches.find((branch) => branch.default);
if (defaultBranch) {
return defaultBranch;
}
throw new NotFoundError(
'No default branch found in this project. Please provide a branch ID.',
);
};
```
--------------------------------------------------------------------------------
/mcp-client/src/cli-client.ts:
--------------------------------------------------------------------------------
```typescript
import { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js';
import readline from 'readline/promises';
import { MCPClient } from './index.js';
import { consoleStyles, Logger } from './logger.js';
const EXIT_COMMAND = 'exit';
export class MCPClientCLI {
private rl: readline.Interface;
private client: MCPClient;
private logger: Logger;
constructor(serverConfig: StdioServerParameters) {
this.client = new MCPClient(serverConfig);
this.logger = new Logger({ mode: 'verbose' });
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
async start() {
try {
this.logger.log(consoleStyles.separator + '\n', { type: 'info' });
this.logger.log('🤖 Interactive Claude CLI\n', { type: 'info' });
this.logger.log(`Type your queries or "${EXIT_COMMAND}" to exit\n`, {
type: 'info',
});
this.logger.log(consoleStyles.separator + '\n', { type: 'info' });
this.client.start();
await this.chat_loop();
} catch (error) {
this.logger.log('Failed to initialize tools: ' + error + '\n', {
type: 'error',
});
process.exit(1);
} finally {
this.rl.close();
process.exit(0);
}
}
private async chat_loop() {
while (true) {
try {
const query = (await this.rl.question(consoleStyles.prompt)).trim();
if (query.toLowerCase() === EXIT_COMMAND) {
this.logger.log('\nGoodbye! 👋\n', { type: 'warning' });
break;
}
await this.client.processQuery(query);
this.logger.log('\n' + consoleStyles.separator + '\n');
} catch (error) {
this.logger.log('\nError: ' + error + '\n', { type: 'error' });
}
}
}
}
```
--------------------------------------------------------------------------------
/src/server/errors.ts:
--------------------------------------------------------------------------------
```typescript
import { isAxiosError } from 'axios';
import { NeonDbError } from '@neondatabase/serverless';
import { logger } from '../utils/logger.js';
import { captureException } from '@sentry/node';
export class InvalidArgumentError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidArgumentError';
}
}
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
export function isClientError(
error: unknown,
): error is InvalidArgumentError | NotFoundError {
return (
error instanceof InvalidArgumentError || error instanceof NotFoundError
);
}
export function errorResponse(error: unknown) {
return {
isError: true,
content: [
{
type: 'text' as const,
text:
error instanceof Error
? `${error.name}: ${error.message}`
: 'Unknown error',
},
],
};
}
export function handleToolError(
error: unknown,
properties: Record<string, string>,
) {
if (error instanceof NeonDbError || isClientError(error)) {
return errorResponse(error);
} else if (
isAxiosError(error) &&
error.response?.status &&
error.response?.status < 500
) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: error.response.data.message,
},
{
type: 'text' as const,
text: `[${error.response.statusText}] ${error.message}`,
},
],
};
} else {
logger.error('Tool call error:', {
error:
error instanceof Error
? `${error.name}: ${error.message}`
: 'Unknown error',
properties,
});
captureException(error, { extra: properties });
return errorResponse(error);
}
}
```
--------------------------------------------------------------------------------
/src/analytics/analytics.ts:
--------------------------------------------------------------------------------
```typescript
import { Analytics } from '@segment/analytics-node';
import { ANALYTICS_WRITE_KEY } from '../constants.js';
import { Api, AuthDetailsResponse } from '@neondatabase/api-client';
import { AuthContext } from '../types/auth.js';
let analytics: Analytics | undefined;
type Account = AuthContext['extra']['account'];
export const initAnalytics = () => {
if (ANALYTICS_WRITE_KEY) {
analytics = new Analytics({
writeKey: ANALYTICS_WRITE_KEY,
host: 'https://track.neon.tech',
});
}
};
export const identify = (
account: Account | null,
params: Omit<Parameters<Analytics['identify']>[0], 'userId' | 'anonymousId'>,
) => {
if (account) {
analytics?.identify({
...params,
userId: account.id,
traits: {
name: account.name,
email: account.email,
isOrg: account.isOrg,
},
});
} else {
analytics?.identify({
...params,
anonymousId: 'anonymous',
});
}
};
export const track = (params: Parameters<Analytics['track']>[0]) => {
analytics?.track(params);
};
/**
* Util for identifying the user based on the auth method. If the api key belongs to an organization, identify the organization instead of user details.
*/
export const identifyApiKey = async (
auth: AuthDetailsResponse,
neonClient: Api<unknown>,
params: Omit<Parameters<Analytics['identify']>[0], 'userId' | 'anonymousId'>,
) => {
if (auth.auth_method === 'api_key_org') {
const { data: org } = await neonClient.getOrganization(auth.account_id);
const account = {
id: auth.account_id,
name: org.name,
isOrg: true,
};
identify(account, params);
return account;
}
const { data: user } = await neonClient.getCurrentUserInfo();
const account = {
id: user.id,
name: user.name,
email: user.email,
isOrg: false,
};
identify(account, params);
return account;
};
```
--------------------------------------------------------------------------------
/src/oauth/kv-store.ts:
--------------------------------------------------------------------------------
```typescript
import { KeyvPostgres, KeyvPostgresOptions } from '@keyv/postgres';
import { logger } from '../utils/logger.js';
import { AuthorizationCode, Client, Token } from 'oauth2-server';
import Keyv from 'keyv';
import { AuthContext } from '../types/auth.js';
import { AuthDetailsResponse } from '@neondatabase/api-client';
const SCHEMA = 'mcpauth';
const createKeyv = <T>(options: KeyvPostgresOptions) =>
new Keyv<T>({ store: new KeyvPostgres(options) });
export const clients = createKeyv<Client>({
connectionString: process.env.OAUTH_DATABASE_URL,
schema: SCHEMA,
table: 'clients',
});
clients.on('error', (err) => {
logger.error('Clients keyv error:', { err });
});
export const tokens = createKeyv<Token>({
connectionString: process.env.OAUTH_DATABASE_URL,
schema: SCHEMA,
table: 'tokens',
});
tokens.on('error', (err) => {
logger.error('Tokens keyv error:', { err });
});
export type RefreshToken = {
refreshToken: string;
refreshTokenExpiresAt?: Date | undefined;
accessToken: string;
};
export const refreshTokens = createKeyv<RefreshToken>({
connectionString: process.env.OAUTH_DATABASE_URL,
schema: SCHEMA,
table: 'refresh_tokens',
});
refreshTokens.on('error', (err) => {
logger.error('Refresh tokens keyv error:', { err });
});
export const authorizationCodes = createKeyv<AuthorizationCode>({
connectionString: process.env.OAUTH_DATABASE_URL,
schema: SCHEMA,
table: 'authorization_codes',
});
authorizationCodes.on('error', (err) => {
logger.error('Authorization codes keyv error:', { err });
});
export type ApiKeyRecord = {
apiKey: string;
authMethod: AuthDetailsResponse['auth_method'];
account: AuthContext['extra']['account'];
};
export const apiKeys = createKeyv<ApiKeyRecord>({
connectionString: process.env.OAUTH_DATABASE_URL,
schema: SCHEMA,
table: 'api_keys',
});
apiKeys.on('error', (err) => {
logger.error('API keys keyv error:', { err });
});
```
--------------------------------------------------------------------------------
/src/views/approval-dialog.pug:
--------------------------------------------------------------------------------
```
- var clientName = client.client_name || 'A new MCP Client'
- var logo = client.logo || client.logo_url || 'https://placehold.co/100x100/EEE/31343C?font=montserrat&text=MCP Client'
- var website = client.client_uri || client.website
- var redirectUris = client.redirect_uris
- var serverName = 'Neon MCP Server'
html(lang='en')
head
meta(charset='utf-8')
meta(name='viewport', content='width=device-width, initial-scale=1')
style
include styles.css
title #{clientName} | Authorization Request
body
div(class='container')
div(class='precard')
a(class="header", href='/', target='_blank')
img(src='/logo.png', alt="Neon MCP", class="logo")
div(class="card")
h2(class="alert")
strong MCP Client Authorization Request
div(class="client-info")
div(class='client-detail')
div(class='detail-label') Name:
div(class='detail-value') #{clientName}
if website
div(class='client-detail')
div(class='detail-label') Website:
div(class='detail-value small')
a(href=website, target='_blank' rel='noopener noreferrer') #{website}
if redirectUris
div(class='client-detail')
div(class='detail-label') Redirect URIs:
div(class='detail-value small')
each uri in redirectUris
div #{uri}
p(class="description") This MCP client is requesting to be authorized
| on #{serverName}. If you approve, you will be redirected to complete the authentication.
form(method='POST', action='/authorize')
input(type='hidden', name='state', value=state)
div(class='actions')
button(type='button', class='button button-secondary' onclick='window.history.back()') Cancel
button(type='submit', class='button button-primary') Approve
```
--------------------------------------------------------------------------------
/src/oauth/client.ts:
--------------------------------------------------------------------------------
```typescript
import { Request } from 'express';
import {
discovery,
buildAuthorizationUrl,
authorizationCodeGrant,
ClientSecretPost,
refreshTokenGrant,
} from 'openid-client';
import {
CLIENT_ID,
CLIENT_SECRET,
UPSTREAM_OAUTH_HOST,
REDIRECT_URI,
SERVER_HOST,
} from '../constants.js';
import { logger } from '../utils/logger.js';
const NEON_MCP_SCOPES = [
'openid',
'offline',
'offline_access',
'urn:neoncloud:projects:create',
'urn:neoncloud:projects:read',
'urn:neoncloud:projects:update',
'urn:neoncloud:projects:delete',
'urn:neoncloud:orgs:create',
'urn:neoncloud:orgs:read',
'urn:neoncloud:orgs:update',
'urn:neoncloud:orgs:delete',
'urn:neoncloud:orgs:permission',
] as const;
const getUpstreamConfig = async () => {
const url = new URL(UPSTREAM_OAUTH_HOST);
const config = await discovery(
url,
CLIENT_ID,
{
client_secret: CLIENT_SECRET,
},
ClientSecretPost(CLIENT_SECRET),
{},
);
return config;
};
export const upstreamAuth = async (state: string) => {
const config = await getUpstreamConfig();
return buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
token_endpoint_auth_method: 'client_secret_post',
scope: NEON_MCP_SCOPES.join(' '),
response_type: 'code',
state,
});
};
export const exchangeCode = async (req: Request) => {
try {
const config = await getUpstreamConfig();
const currentUrl = new URL(req.originalUrl, SERVER_HOST);
return await authorizationCodeGrant(config, currentUrl, {
expectedState: req.query.state as string,
idTokenExpected: true,
});
} catch (error: unknown) {
logger.error('failed to exchange code:', {
message: error instanceof Error ? error.message : 'Unknown error',
error,
});
throw error;
}
};
export const exchangeRefreshToken = async (token: string) => {
const config = await getUpstreamConfig();
return refreshTokenGrant(config, token);
};
```
--------------------------------------------------------------------------------
/landing/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
```typescript
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
```
--------------------------------------------------------------------------------
/src/tools/handlers/connection-string.ts:
--------------------------------------------------------------------------------
```typescript
import { Api } from '@neondatabase/api-client';
import { ToolHandlerExtraParams } from '../types.js';
import { startSpan } from '@sentry/node';
import { getDefaultDatabase } from '../utils.js';
import { getDefaultBranch, getOnlyProject } from './utils.js';
export async function handleGetConnectionString(
{
projectId,
branchId,
computeId,
databaseName,
roleName,
}: {
projectId?: string;
branchId?: string;
computeId?: string;
databaseName?: string;
roleName?: string;
},
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
) {
return await startSpan(
{
name: 'get_connection_string',
},
async () => {
// If projectId is not provided, get the first project but only if there is only one project
if (!projectId) {
const project = await getOnlyProject(neonClient, extra);
projectId = project.id;
}
if (!branchId) {
const defaultBranch = await getDefaultBranch(projectId, neonClient);
branchId = defaultBranch.id;
}
// If databaseName is not provided, use default `neondb` or first database
let dbObject;
if (!databaseName) {
dbObject = await getDefaultDatabase(
{
projectId,
branchId,
databaseName,
},
neonClient,
);
databaseName = dbObject.name;
if (!roleName) {
roleName = dbObject.owner_name;
}
} else if (!roleName) {
const { data } = await neonClient.getProjectBranchDatabase(
projectId,
branchId,
databaseName,
);
roleName = data.database.owner_name;
}
// Get connection URI with the provided parameters
const connectionString = await neonClient.getConnectionUri({
projectId,
role_name: roleName,
database_name: databaseName,
branch_id: branchId,
endpoint_id: computeId,
});
return {
uri: connectionString.data.uri,
projectId,
branchId,
databaseName,
roleName,
computeId,
};
},
);
}
```
--------------------------------------------------------------------------------
/landing/components/ui/button.tsx:
--------------------------------------------------------------------------------
```typescript
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: 'h-7 px-2.5 has-[>svg]:px-2',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
```
--------------------------------------------------------------------------------
/src/oauth/model.ts:
--------------------------------------------------------------------------------
```typescript
import {
AuthorizationCode,
AuthorizationCodeModel,
Client,
Token,
User,
} from 'oauth2-server';
import {
clients,
tokens,
refreshTokens,
authorizationCodes,
RefreshToken,
} from './kv-store.js';
class Model implements AuthorizationCodeModel {
getClient: (
clientId: string,
clientSecret: string,
) => Promise<Client | undefined> = async (clientId) => {
return clients.get(clientId);
};
saveClient: (client: Client) => Promise<Client> = async (client) => {
await clients.set(client.id, client);
return client;
};
saveToken: (token: Token) => Promise<Token> = async (token) => {
await tokens.set(token.accessToken, token);
return token;
};
deleteToken: (token: Token) => Promise<boolean> = async (token) => {
return tokens.delete(token.accessToken);
};
saveRefreshToken: (token: RefreshToken) => Promise<RefreshToken> = async (
token,
) => {
await refreshTokens.set(token.refreshToken, token);
return token;
};
deleteRefreshToken: (token: RefreshToken) => Promise<boolean> = async (
token,
) => {
return refreshTokens.delete(token.refreshToken);
};
validateScope: (
user: User,
client: Client,
scope: string,
) => Promise<string> = (user, client, scope) => {
// For demo purposes, accept all scopes
return Promise.resolve(scope);
};
verifyScope: (token: Token, scope: string) => Promise<boolean> = () => {
// For demo purposes, accept all scopes
return Promise.resolve(true);
};
getAccessToken: (accessToken: string) => Promise<Token | undefined> = async (
accessToken,
) => {
const token = await tokens.get(accessToken);
return token;
};
getRefreshToken: (refreshToken: string) => Promise<RefreshToken | undefined> =
async (refreshToken) => {
return refreshTokens.get(refreshToken);
};
saveAuthorizationCode: (
code: AuthorizationCode,
) => Promise<AuthorizationCode> = async (code) => {
await authorizationCodes.set(code.authorizationCode, code);
return code;
};
getAuthorizationCode: (
code: string,
) => Promise<AuthorizationCode | undefined> = async (code) => {
return authorizationCodes.get(code);
};
revokeAuthorizationCode: (code: AuthorizationCode) => Promise<boolean> =
async (code) => {
return authorizationCodes.delete(code.authorizationCode);
};
}
export const model = new Model();
```
--------------------------------------------------------------------------------
/src/tools/handlers/describe-branch.ts:
--------------------------------------------------------------------------------
```typescript
import { Api, Branch } from '@neondatabase/api-client';
import { ToolHandlerExtraParams } from '../types.js';
import { handleGetConnectionString } from './connection-string.js';
import { neon } from '@neondatabase/serverless';
import { DESCRIBE_DATABASE_STATEMENTS } from '../utils.js';
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { CONSOLE_URLS, generateConsoleUrl } from './urls.js';
const branchInfo = (branch: Branch) => {
return `Branch Details:
Name: ${branch.name}
ID: ${branch.id}
Parent Branch: ${branch.parent_id}
Default: ${branch.default}
Protected: ${branch.protected ? 'Yes' : 'No'}
${branch.created_by ? `Created By: ${branch.created_by.name}` : ''}
Created: ${new Date(branch.created_at).toLocaleDateString()}
Updated: ${new Date(branch.updated_at).toLocaleDateString()}
Compute Usage: ${branch.compute_time_seconds} seconds
Written Data: ${branch.written_data_bytes} bytes
Data Transfer: ${branch.data_transfer_bytes} bytes
Console Link: ${generateConsoleUrl(CONSOLE_URLS.PROJECT_BRANCH, {
projectId: branch.project_id,
branchId: branch.id,
})}
`;
};
export async function handleDescribeBranch(
{
projectId,
databaseName,
branchId,
}: {
projectId: string;
databaseName?: string;
branchId: string;
},
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
): Promise<CallToolResult> {
const { data: branchData } = await neonClient.getProjectBranch(
projectId,
branchId,
);
const branch = branchData.branch;
let response: Record<string, any>[][];
try {
const connectionString = await handleGetConnectionString(
{
projectId,
branchId: branch.id,
databaseName,
},
neonClient,
extra,
);
const runQuery = neon(connectionString.uri);
response = await runQuery.transaction(
DESCRIBE_DATABASE_STATEMENTS.map((sql) => runQuery.query(sql)),
);
return {
content: [
{
type: 'text',
text: branchInfo(branch),
metadata: branch,
},
{
type: 'text',
text: ['Database Structure:', JSON.stringify(response, null, 2)].join(
'\n',
),
databasetree: response,
},
],
};
} catch {
// Ignore database connection errors
}
return {
content: [
{
type: 'text',
text: branchInfo(branch),
},
],
};
}
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { identifyApiKey, initAnalytics, track } from './analytics/analytics.js';
import { NODE_ENV } from './constants.js';
import { handleInit, parseArgs } from './initConfig.js';
import { createNeonClient, getPackageJson } from './server/api.js';
import { createMcpServer } from './server/index.js';
import { createSseTransport } from './transports/sse-express.js';
import { startStdio } from './transports/stdio.js';
import { logger } from './utils/logger.js';
import { AppContext } from './types/context.js';
import { NEON_TOOLS } from './tools/index.js';
import './utils/polyfills.js';
const args = parseArgs();
const appVersion = getPackageJson().version;
const appName = getPackageJson().name;
if (args.command === 'export-tools') {
console.log(
JSON.stringify(
NEON_TOOLS.map((item) => ({ ...item, inputSchema: undefined })),
null,
2,
),
);
process.exit(0);
}
const appContext: AppContext = {
environment: NODE_ENV,
name: appName,
version: appVersion,
transport: 'stdio',
};
if (args.analytics) {
initAnalytics();
}
if (args.command === 'start:sse') {
createSseTransport({
...appContext,
transport: 'sse',
});
} else {
// Turn off logger in stdio mode to avoid capturing stderr in wrong format by host application (Claude Desktop)
logger.silent = true;
try {
const neonClient = createNeonClient(args.neonApiKey);
const { data } = await neonClient.getAuthDetails();
const accountId = data.account_id;
const account = await identifyApiKey(data, neonClient, {
context: appContext,
});
if (args.command === 'init') {
track({
userId: accountId,
event: 'init_stdio',
context: appContext,
});
handleInit({
executablePath: args.executablePath,
neonApiKey: args.neonApiKey,
analytics: args.analytics,
});
process.exit(0);
}
if (args.command === 'start') {
track({
userId: accountId,
event: 'start_stdio',
context: appContext,
});
const server = createMcpServer({
apiKey: args.neonApiKey,
account,
app: appContext,
});
await startStdio(server);
}
} catch (error) {
console.error('Server error:', error);
track({
anonymousId: 'anonymous',
event: 'server_error',
properties: { error },
context: appContext,
});
process.exit(1);
}
}
```
--------------------------------------------------------------------------------
/src/transports/stream.ts:
--------------------------------------------------------------------------------
```typescript
import { Request, Response, Router } from 'express';
import { AppContext } from '../types/context.js';
import { createMcpServer } from '../server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { logger } from '../utils/logger.js';
import { track } from '../analytics/analytics.js';
import { requiresAuth } from '../oauth/utils.js';
export const createStreamTransport = (appContext: AppContext) => {
const router = Router();
router.post('/', requiresAuth(), async (req: Request, res: Response) => {
const auth = req.auth;
if (!auth) {
res.status(401).send('Unauthorized');
return;
}
try {
const server = createMcpServer({
apiKey: auth.token,
client: auth.extra.client,
account: auth.extra.account,
app: appContext,
readOnly: auth.extra.readOnly,
userAgent: req.headers['user-agent'],
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on('close', () => {
void transport.close();
void server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error: unknown) {
logger.error('Failed to connect to MCP server:', {
message: error instanceof Error ? error.message : 'Unknown error',
error,
});
track({
userId: auth.extra.account.id,
event: 'stream_connection_errored',
properties: { error },
context: {
app: appContext,
client: auth.extra.client,
},
});
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
});
router.get('/', requiresAuth(), (req: Request, res: Response) => {
logger.info('Received GET MCP request');
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.',
},
id: null,
}),
);
});
router.delete('/', requiresAuth(), (req: Request, res: Response) => {
logger.info('Received DELETE MCP request');
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.',
},
id: null,
}),
);
});
return router;
};
```
--------------------------------------------------------------------------------
/src/resources.ts:
--------------------------------------------------------------------------------
```typescript
import { ReadResourceCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Resource } from '@modelcontextprotocol/sdk/types.js';
export async function fetchRawGithubContent(rawPath: string) {
const path = rawPath.replace('/blob', '');
const response = await fetch(`https://raw.githubusercontent.com${path}`);
if (!response.ok) {
throw new Error(
`Failed to fetch GitHub content: ${response.status} ${response.statusText}`,
);
}
return response.text();
}
export const NEON_RESOURCES = [
{
name: 'neon-auth',
uri: 'https://github.com/neondatabase-labs/ai-rules/blob/main/neon-auth.mdc',
mimeType: 'text/plain',
description: 'Neon Auth usage instructions',
handler: async (url) => {
const uri = url.host;
const rawPath = url.pathname;
const content = await fetchRawGithubContent(rawPath);
return {
contents: [
{
uri: uri,
mimeType: 'text/plain',
text: content,
},
],
};
},
},
{
name: 'neon-serverless',
uri: 'https://github.com/neondatabase-labs/ai-rules/blob/main/neon-serverless.mdc',
mimeType: 'text/plain',
description: 'Neon Serverless usage instructions',
handler: async (url) => {
const uri = url.host;
const rawPath = url.pathname;
const content = await fetchRawGithubContent(rawPath);
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: content,
},
],
};
},
},
{
name: 'neon-drizzle',
uri: 'https://github.com/neondatabase-labs/ai-rules/blob/main/neon-drizzle.mdc',
mimeType: 'text/plain',
description: 'Neon Drizzle usage instructions',
handler: async (url) => {
const uri = url.host;
const rawPath = url.pathname;
const content = await fetchRawGithubContent(rawPath);
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: content,
},
],
};
},
},
{
name: 'neon-get-started',
uri: 'https://github.com/neondatabase-labs/ai-rules/blob/main/neon-get-started.mdc',
mimeType: 'text/markdown',
description: 'Neon getting started guide',
handler: async (url) => {
const uri = url.host;
const rawPath = url.pathname;
const content = await fetchRawGithubContent(rawPath);
return {
contents: [
{
uri,
mimeType: 'text/markdown',
text: content,
},
],
};
},
},
] satisfies (Resource & { handler: ReadResourceCallback })[];
```
--------------------------------------------------------------------------------
/src/tools/handlers/neon-auth.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { Api, NeonAuthSupportedAuthProvider } from '@neondatabase/api-client';
import { provisionNeonAuthInputSchema } from '../toolsSchema.js';
import { z } from 'zod';
import { getDefaultDatabase } from '../utils.js';
import { getDefaultBranch } from './utils.js';
import { ToolHandlerExtraParams } from '../types.js';
type Props = z.infer<typeof provisionNeonAuthInputSchema>;
export async function handleProvisionNeonAuth(
{ projectId, branchId, databaseName }: Props,
neonClient: Api<unknown>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_extra: ToolHandlerExtraParams,
): Promise<CallToolResult> {
// If branchId is not provided, use the default branch
let resolvedBranchId = branchId;
if (!resolvedBranchId) {
const defaultBranch = await getDefaultBranch(projectId, neonClient);
resolvedBranchId = defaultBranch.id;
}
const defaultDatabase = await getDefaultDatabase(
{
projectId,
branchId: resolvedBranchId,
databaseName,
},
neonClient,
);
if (!defaultDatabase) {
return {
isError: true,
content: [
{
type: 'text',
text: databaseName
? `The branch has no database named '${databaseName}'.`
: 'The branch has no databases.',
},
],
};
}
const response = await neonClient.createNeonAuth(
projectId,
resolvedBranchId,
{
auth_provider: NeonAuthSupportedAuthProvider.BetterAuth,
database_name: defaultDatabase.name,
},
);
// In case of 409, it means that the integration already exists
// We should not return an error, but a message that the integration already exists and fetch the existing integration
if (response.status === 409) {
return {
content: [
{
type: 'text',
text: 'Neon Auth already provisioned.',
},
],
};
}
if (response.status !== 201) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to provision Neon Auth. Error: ${response.statusText}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: `Authentication has been successfully provisioned for your Neon project and branch.
\`\`\`
Use this URL to access the Neon Auth through your better auth compatible client: ${response.data.base_url}
\`\`\`
`,
},
{
type: 'text',
text: `
Use Following JWKS URL to retrieve the public key to verify the JSON Web Tokens (JWT) issued by authentication provider:
\`\`\`
${response.data.jwks_url}
\`\`\`
`,
},
],
};
}
```
--------------------------------------------------------------------------------
/landing/icons/neon.svg:
--------------------------------------------------------------------------------
```
<svg width="26px" height="26px" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden="true"><g clip-path="url(#clip0_8136_183)"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 10C0 4.47715 4.47705 0 9.99976 0H47.9989C53.5216 0 57.9986 4.47715 57.9986 10V42.3189C57.9986 48.0326 50.7684 50.5124 47.2618 46.0014L36.2991 31.8988V49C36.2991 53.9706 32.2698 58 27.2993 58H9.99976C4.47705 58 0 53.5228 0 48V10ZM9.99976 8C8.89522 8 7.99981 8.89543 7.99981 10V48C7.99981 49.1046 8.89522 50 9.99976 50H27.5993C28.1516 50 28.2993 49.5523 28.2993 49V26.0673C28.2993 20.3536 35.5295 17.8738 39.0361 22.3848L49.9988 36.4874V10C49.9988 8.89543 50.1034 8 48.9988 8H9.99976Z" fill="#32C0ED"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M0 10C0 4.47715 4.47705 0 9.99976 0H47.9989C53.5216 0 57.9986 4.47715 57.9986 10V42.3189C57.9986 48.0326 50.7684 50.5124 47.2618 46.0014L36.2991 31.8988V49C36.2991 53.9706 32.2698 58 27.2993 58H9.99976C4.47705 58 0 53.5228 0 48V10ZM9.99976 8C8.89522 8 7.99981 8.89543 7.99981 10V48C7.99981 49.1046 8.89522 50 9.99976 50H27.5993C28.1516 50 28.2993 49.5523 28.2993 49V26.0673C28.2993 20.3536 35.5295 17.8738 39.0361 22.3848L49.9988 36.4874V10C49.9988 8.89543 50.1034 8 48.9988 8H9.99976Z" fill="url(#paint0_linear_8136_183)"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M0 10C0 4.47715 4.47705 0 9.99976 0H47.9989C53.5216 0 57.9986 4.47715 57.9986 10V42.3189C57.9986 48.0326 50.7684 50.5124 47.2618 46.0014L36.2991 31.8988V49C36.2991 53.9706 32.2698 58 27.2993 58H9.99976C4.47705 58 0 53.5228 0 48V10ZM9.99976 8C8.89522 8 7.99981 8.89543 7.99981 10V48C7.99981 49.1046 8.89522 50 9.99976 50H27.5993C28.1516 50 28.2993 49.5523 28.2993 49V26.0673C28.2993 20.3536 35.5295 17.8738 39.0361 22.3848L49.9988 36.4874V10C49.9988 8.89543 50.1034 8 48.9988 8H9.99976Z" fill="url(#paint1_linear_8136_183)" fill-opacity="0.35"></path><path d="M48.0003 0C53.523 0 58 4.47715 58 10V42.3189C58 48.0326 50.7699 50.5124 47.2633 46.0014L36.3006 31.8988V49C36.3006 53.9706 32.2712 58 27.3008 58C27.8531 58 28.3008 57.5523 28.3008 57V26.0673C28.3008 20.3536 35.5309 17.8738 39.0375 22.3848L50.0002 36.4874V2C50.0002 0.89543 49.1048 0 48.0003 0Z" fill="#63F655"></path></g><defs><linearGradient id="paint0_linear_8136_183" x1="57.9986" y1="58" x2="6.99848" y2="0.0012267" gradientUnits="userSpaceOnUse"><stop stop-color="#2EF51C"></stop><stop offset="1" stop-color="#2EF51C" stop-opacity="0"></stop></linearGradient><linearGradient id="paint1_linear_8136_183" x1="57.9986" y1="58" x2="23.5492" y2="44.6006" gradientUnits="userSpaceOnUse"><stop stop-opacity="0.9"></stop><stop offset="1" stop-color="#1A1A1A" stop-opacity="0"></stop></linearGradient><clipPath id="clip0_8136_183"><rect width="58" height="58" fill="white"></rect></clipPath></defs></svg>
```
--------------------------------------------------------------------------------
/src/views/styles.css:
--------------------------------------------------------------------------------
```css
/* Modern, responsive styling with system fonts */
:root {
--primary-color: #0070f3;
--error-color: #f44336;
--border-color: #e5e7eb;
--text-color: #dedede;
--text-color-secondary: #949494;
--background-color: #1c1c1c;
--border-color: #2a2929;
--card-shadow: 0 0px 12px 0px rgb(0 230 153 / 0.3);
--link-color: rgb(0 230 153 / 1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
line-height: 1.6;
color: var(--text-color);
background-color: var(--background-color);
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 2rem auto;
padding: 1rem;
}
.precard {
padding: 2rem;
text-align: center;
}
.card {
background-color: #0a0c09e6;
border-radius: 8px;
box-shadow: var(--card-shadow);
padding: 2rem 2rem 0.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
color: var(--text-color);
text-decoration: none;
}
.logo {
width: 48px;
height: 48px;
margin-right: 1rem;
border-radius: 8px;
object-fit: contain;
}
.title {
margin: 0;
font-size: 1.3rem;
font-weight: 400;
}
.alert {
margin: 0;
font-size: 1.5rem;
font-weight: 400;
margin: 1rem 0;
text-align: center;
}
.description {
color: var(--text-color-secondary);
}
.client-info {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem 1rem 0.5rem;
margin-bottom: 1.5rem;
}
.client-name {
font-weight: 600;
font-size: 1.2rem;
margin: 0 0 0.5rem 0;
}
.client-detail {
display: flex;
margin-bottom: 0.5rem;
align-items: baseline;
}
.detail-label {
font-weight: 500;
min-width: 120px;
}
.detail-value {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
word-break: break-all;
}
.detail-value a {
color: inherit;
text-decoration: underline;
}
.detail-value.small {
font-size: 0.8em;
}
.external-link-icon {
font-size: 0.75em;
margin-left: 0.25rem;
vertical-align: super;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
.button {
padding: 0.65rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
border: none;
font-size: 1rem;
}
.button-primary {
background-color: rgb(0 229 153 / 1);
color: rgb(26 26 26 / 1);
}
.button-secondary {
background-color: transparent;
border: 1px solid rgb(73 75 80 / 1);
color: var(--text-color);
}
/* Responsive adjustments */
@media (max-width: 640px) {
.container {
margin: 1rem auto;
padding: 0.5rem;
}
.card {
padding: 1.5rem;
}
.client-detail {
flex-direction: column;
}
.detail-label {
min-width: unset;
margin-bottom: 0.25rem;
}
.actions {
flex-direction: column;
}
.button {
width: 100%;
}
}
```
--------------------------------------------------------------------------------
/landing/components/Introduction.tsx:
--------------------------------------------------------------------------------
```typescript
import { cn } from '@/lib/utils';
import { ExternalLink } from '@/components/ExternalLink';
import { CopyableUrl } from '@/components/CopyableUrl';
export const Introduction = ({ className }: { className?: string }) => (
<div className={cn('flex flex-col gap-2', className)}>
<desc className="text-xl mb-2">
Manage your Neon Postgres databases with natural language.
</desc>
<CopyableUrl url="https://mcp.neon.tech/mcp" />
<div>
The <strong className="font-semibold">Neon MCP Server</strong> lets AI
agents and dev tools like Cursor interact with Neon by translating plain
English into{' '}
<ExternalLink href="https://api-docs.neon.tech/reference/getting-started-with-neon-api">
Neon API
</ExternalLink>{' '}
calls—no code required. You can create databases, run queries, and make
schema changes just by typing commands like "Create a database named
'my-new-database'" or "List all my Neon projects".
</div>
<div>
Built on the{' '}
<ExternalLink href="https://modelcontextprotocol.org/">
Model Context Protocol (MCP)
</ExternalLink>
, the server bridges natural language and the Neon API to support actions
like creating projects, managing branches, running queries, and handling
migrations.
<br />
<ExternalLink href="https://neon.tech/docs/ai/neon-mcp-server">
Learn more in the docs
</ExternalLink>
</div>
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 border border-emerald-200 rounded-lg p-4 my-2">
<p className="text-sm">
<strong className="font-semibold">Quick setup:</strong> Run{' '}
<code className="bg-white px-2 py-0.5 rounded text-sm border border-emerald-200">
npx neonctl@latest init
</code>{' '}
to authenticate via OAuth, automatically create a Neon API key, and
configure Cursor, VS Code, or Claude Code CLI to connect to the Neon MCP
Server. Then ask your AI assistant "Get started with Neon".{' '}
<ExternalLink href="https://neon.tech/docs/reference/cli-init">
Learn more in the docs
</ExternalLink>
</p>
</div>
<div className="mt-4">
<h3 className="text-lg font-semibold mb-2">Read-Only Version</h3>
<div className="flex flex-col gap-3">
<div>
<p className="text-sm mb-2">
Safe for cloud environments. All transactions are read-only -
perfect for querying and analyzing data without modification risks.
</p>
<p className="text-xs text-muted-foreground">
Enable read-only mode by adding the{' '}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
x-read-only: true
</code>{' '}
header in your MCP configuration.
</p>
</div>
</div>
</div>
</div>
);
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@neondatabase/mcp-server-neon",
"version": "0.6.4",
"description": "MCP server for interacting with Neon Management API and databases",
"license": "MIT",
"author": "Neon, Inc. (https://neon.tech/)",
"homepage": "https://github.com/neondatabase/mcp-server-neon/",
"bugs": "https://github.com/neondatabase/mcp-server-neon/issues",
"type": "module",
"access": "public",
"bin": {
"mcp-server-neon": "./dist/index.js"
},
"files": [
"dist",
"CHANGELOG.md"
],
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc && npm run build:chmod && npm run export-tools && npm run build:landing",
"build:chmod": "node -e \"require('fs').chmodSync('dist/index.js', '755')\"",
"build:landing": "cd landing/ && npm run build && cp -r out/* ../public",
"watch": "tsc-watch --onSuccess \"chmod 755 dist/index.js\"",
"inspector": "npx @modelcontextprotocol/inspector dist/index.js",
"format": "prettier --write .",
"lint": "npm run typecheck && eslint src && prettier --check .",
"lint:fix": "npm run typecheck && eslint src --fix && prettier --w .",
"prerelease": "npm run build",
"prepublishOnly": "bun scripts/before-publish.ts",
"test": "npx braintrust eval src/tools-evaluations",
"start": "node dist/index.js start",
"start:sse": "node dist/index.js start:sse",
"export-tools": "node dist/index.js export-tools > landing/tools.json"
},
"dependencies": {
"@keyv/postgres": "2.1.2",
"@modelcontextprotocol/sdk": "1.11.2",
"@neondatabase/api-client": "2.5.0",
"@neondatabase/serverless": "1.0.0",
"@radix-ui/react-accordion": "1.2.11",
"@segment/analytics-node": "2.2.1",
"@sentry/node": "9.19.0",
"@tailwindcss/postcss": "4.1.10",
"axios": "1.13.2",
"body-parser": "2.2.0",
"chalk": "5.3.0",
"class-variance-authority": "0.7.1",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"dotenv": "16.4.7",
"express": "5.0.1",
"keyv": "5.3.2",
"lucide-react": "0.515.0",
"morgan": "1.10.0",
"next": "15.3.3",
"node-fetch": "2.7.0",
"oauth2-server": "3.1.1",
"openid-client": "6.3.4",
"pug": "3.0.3",
"react-syntax-highlighter": "15.6.1",
"tailwind-merge": "3.3.1",
"winston": "3.17.0",
"zod": "3.24.1"
},
"devDependencies": {
"@eslint/js": "9.21.0",
"@types/cookie-parser": "1.4.8",
"@types/cors": "2.8.17",
"@types/express": "5.0.1",
"@types/morgan": "1.9.9",
"@types/node": "20.17.9",
"@types/node-fetch": "2.6.12",
"@types/oauth2-server": "3.0.18",
"@types/react": "19.1.8",
"autoevals": "0.0.111",
"braintrust": "0.0.177",
"bun": "1.1.40",
"eslint": "9.21.0",
"eslint-config-prettier": "10.0.2",
"prettier": "3.4.1",
"tsc-watch": "6.2.1",
"typescript": "5.7.2",
"typescript-eslint": "v8.25.0"
},
"engines": {
"node": ">=22.0.0"
},
"resolutions": {
"axios": "1.13.2"
}
}
```
--------------------------------------------------------------------------------
/.github/workflows/claude.yml:
--------------------------------------------------------------------------------
```yaml
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}-${{ github.actor }}
cancel-in-progress: true
jobs:
claude:
if: |
(
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') &&
(github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
(github.event.issue.author_association == 'OWNER' ||
github.event.issue.author_association == 'MEMBER' ||
github.event.issue.author_association == 'COLLABORATOR'))
)
runs-on:
group: neondatabase-protected-runner-group
labels: linux-ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh pr:*),Bash(gh search:*)"'
```
--------------------------------------------------------------------------------
/.github/workflows/koyeb-preview.yml:
--------------------------------------------------------------------------------
```yaml
name: Build and deploy backend to preview
on:
workflow_dispatch:
pull_request:
types: [synchronize, labeled]
jobs:
deploy:
concurrency:
group: '${{ github.ref_name }}'
cancel-in-progress: true
runs-on:
group: neondatabase-protected-runner-group
labels: linux-ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'deploy-preview')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install and configure the Koyeb CLI
uses: koyeb-community/koyeb-actions@v2
with:
api_token: '${{ secrets.KOYEB_PREVIEW_TOKEN }}'
- name: Build and deploy to Koyeb preview
run: |
koyeb deploy . platform-koyeb-preview/main \
--instance-type nano \
--region was \
--archive-builder docker \
--archive-docker-dockerfile remote.Dockerfile \
--privileged \
--type web \
--port 3001:http \
--route /:3001 \
--wait \
--env CLIENT_ID=${{secrets.CLIENT_ID}} \
--env CLIENT_SECRET=${{secrets.CLIENT_SECRET}} \
--env OAUTH_DATABASE_URL=${{secrets.PREVIEW_OAUTH_DATABASE_URL}} \
--env SERVER_HOST=${{vars.KOYEB_PREVIEW_SERVER_HOST}} \
--env NEON_API_HOST=${{vars.NEON_API_HOST_STAGING}} \
--env UPSTREAM_OAUTH_HOST=${{vars.OAUTH_HOST_STAGING}} \
--env COOKIE_SECRET=${{secrets.COOKIE_SECRET}} \
- name: Comment on PR with deployment URL
if: ${{ github.event_name == 'pull_request' && success() }}
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// GitHub bot id taken from (https://api.github.com/users/github-actions[bot])
const githubActionsBotId = 41898282
const ownerRepoParams = {
owner: context.repo.owner,
repo: context.repo.repo,
}
// Hidden start marker for the comment
const startMarker = '<!-- Preview Deployment Comment-->';
const body = `${startMarker}
🚀 Preview deployment ready: [https://preview-mcp.neon.tech](https://preview-mcp.neon.tech)`;
const comments = await github.paginate(github.rest.issues.listComments, {
...ownerRepoParams,
issue_number: context.issue.number,
});
// Delete previous comments regarding preview deployments.
for (comment of comments.filter(comment => comment.user.id === githubActionsBotId && comment.body.startsWith(startMarker))) {
await github.rest.issues.deleteComment({
comment_id: comment.id,
...ownerRepoParams,
})
}
await github.rest.issues.createComment({
...ownerRepoParams,
issue_number: context.issue.number,
body
});
```
--------------------------------------------------------------------------------
/landing/components/DescriptionItem.tsx:
--------------------------------------------------------------------------------
```typescript
import {
DescriptionItem,
DescriptionItemType,
TextBlock,
} from '@/lib/description';
import { CodeSnippet } from '@/components/CodeSnippet';
import {
Alert,
AlertDescription,
AlertTitle,
AlertVariant,
} from '@/components/ui/alert';
import {
Terminal,
CircleAlert,
Lightbulb,
BadgeInfo,
Workflow,
SquareArrowRight,
Component,
BookOpenCheck,
} from 'lucide-react';
const ALERT_VARIANT_PER_DESCRIPTION_TYPE: Record<
DescriptionItemType,
{
variant: AlertVariant;
icon: typeof Component;
}
> = {
use_case: { variant: 'default', icon: BookOpenCheck },
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 },
do_not_include: { variant: 'destructive', icon: CircleAlert },
error_handling: { variant: 'destructive', icon: CircleAlert },
};
export const TextBlockUi = (block: TextBlock) => {
if (block.type === 'text') {
return (
<div className="text-sm/[24px]">
{block.content.map((item, index) =>
item.type === 'text' ? (
item.content
) : (
<span key={index} className="monospaced bg-secondary p-1 py-0.25">
{item.content}
</span>
),
)}
</div>
);
}
return <CodeSnippet type={block.syntax}>{block.content}</CodeSnippet>;
};
export const DescriptionItemUi = (item: DescriptionItem) => {
if (item.type === 'text') {
return (
<div className="whitespace-pre-line">
{item.content.map((childItem, index) => (
<TextBlockUi key={index} {...childItem} />
))}
</div>
);
}
// If an example section contains only code snippet then render snippet
// without a section wrapper. An extra wrapper makes the code less readable.
if (
item.type === 'example' &&
item.content.length === 1 &&
item.content[0].type === 'text' &&
item.content[0].content.length === 1 &&
item.content[0].content[0].type === 'code'
) {
const snippet = item.content[0].content[0];
return <CodeSnippet type={snippet.syntax}>{snippet.content}</CodeSnippet>;
}
const { variant, icon: IconComp } =
ALERT_VARIANT_PER_DESCRIPTION_TYPE[item.type];
return (
<Alert variant={variant} className="my-2">
<IconComp className="w-4 h-4" />
<AlertTitle className="first-letter:capitalize font-semibold">
{item.type.replaceAll('_', ' ')}
</AlertTitle>
<AlertDescription className="whitespace-pre-line">
<DescriptionItemsUi description={item.content} />
</AlertDescription>
</Alert>
);
};
export const DescriptionItemsUi = ({
description,
}: {
description: DescriptionItem[];
}) => (
<div className="flex flex-col">
{description.map((item, index) => (
<DescriptionItemUi key={index} {...item} />
))}
</div>
);
```
--------------------------------------------------------------------------------
/landing/app/page.tsx:
--------------------------------------------------------------------------------
```typescript
import fs from 'fs/promises';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { parseDescription } from '@/lib/description';
import { DescriptionItemsUi } from '@/components/DescriptionItem';
import { Introduction } from '@/components/Introduction';
import { Header } from '@/components/Header';
type ToolDescription = {
name: string;
description: string;
};
export default async function Home() {
const packageJson = await fs.readFile('../package.json', 'utf-8');
const packageVersion = JSON.parse(packageJson).version as number;
const toolsJson = await fs.readFile('./tools.json', 'utf-8');
const rawTools = JSON.parse(toolsJson) as ToolDescription[];
const tools = rawTools.map(({ description, ...data }) => ({
...data,
description: parseDescription(description),
}));
return (
<div className="flex flex-col items-center min-h-screen p-4 pb-0 sm:p-8 sm:pb-0">
<main className="w-full max-w-3xl">
<article className="flex flex-col gap-10">
<Header packageVersion={packageVersion} />
<Introduction />
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-yellow-700">
<strong>Security Notice:</strong> The Neon MCP Server grants
powerful database management capabilities through natural
language requests. Please review our{' '}
<a
href="https://neon.tech/docs/ai/neon-mcp-server#mcp-security-guidance"
className="underline hover:text-yellow-800"
target="_blank"
rel="noopener noreferrer"
>
MCP security guidance
</a>{' '}
before using.
</p>
</div>
</div>
</div>
<section id="tools">
<h2 className="text-2xl font-bold mb-2 border-b-3 border-b-emerald-600">
Available Tools
</h2>
{tools === undefined ? (
<div>tools.json is not found</div>
) : (
<Accordion type="multiple" asChild>
<ul>
{tools.map(({ name, description }) => (
<AccordionItem key={name} value={name} asChild>
<li key={name}>
<AccordionTrigger className="flex items-center">
<h3 className="monospaced text-xl font-semibold">
{name}
</h3>
</AccordionTrigger>
<AccordionContent>
<DescriptionItemsUi description={description} />
</AccordionContent>
</li>
</AccordionItem>
))}
</ul>
</Accordion>
)}
</section>
</article>
</main>
<footer className="text-center w-full p-4 mt-10">Neon Inc. 2025</footer>
</div>
);
}
```
--------------------------------------------------------------------------------
/src/prompts.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod';
import { fetchRawGithubContent } from './resources.js';
import { ToolHandlerExtraParams } from './tools/types.js';
import { ClientApplication } from './utils/client-application.js';
export const setupNeonAuthViteReactArgsSchema = {
projectId: z
.string()
.optional()
.describe(
'Optional Neon project ID. If not provided, the guide will help discover available projects.',
),
branchId: z
.string()
.optional()
.describe(
'Optional branch ID. If not provided, the default branch will be used.',
),
databaseName: z
.string()
.optional()
.describe(
'Optional database name. If not provided, the default database (neondb) will be used.',
),
} as const;
export const NEON_PROMPTS = [
{
name: 'setup-neon-auth',
description:
'Interactive guide for setting up Neon Auth in a Vite+React project. Walks through provisioning, package installation, client setup, and UI components.',
argsSchema: setupNeonAuthViteReactArgsSchema,
},
] as const;
function getClientSpecificInstructions(clientApplication: ClientApplication) {
switch (clientApplication) {
case 'cursor':
return `
- **For URLs:** Use the \`@Web\` tool (or \`web_search\`) to fetch the page.
- **For Files:** Use \`grep\` or \`cat\` to read local files.
`;
case 'claude-code':
case 'claude-desktop':
return `
- **For URLs:** Use your \`web_fetch\` tool (or \`web_search\`) to read content.
- **For Files:** Use \`grep\` or \`cat\` to read local files.
`;
default:
return `
- **For URLs:** Use your web fetch tool to read content, or curl if you need to.
- **For Files:** Use \`grep\` or \`cat\` to read local files.
`;
}
}
const COMMON_FOLLOW_INSTRUCTIONS = (clientApplication: ClientApplication) => `
# Just-in-Time Context Protocol
## 1. ASSESSMENT & TRIGGER
- **Analyze Gaps:** Before generating code, ask: "Do I have the *exact* API signatures and patterns for this specific task in my context?"
- **Lazy Loading:** Do NOT read external links or docs if the current context is sufficient.
- **Trigger:** IF you lack specific details AND a reference link/path exists in the rules (e.g., \`[Full Setup](url)\`), you MUST fetch that resource immediately.
## 2. STRICT LINK OBEDIENCE
- **No Guessing:** If a task relates to a linked topic (e.g., "Styling"), you are PROHIBITED from inferring patterns. You must read the linked reference.
- **Method:**
${getClientSpecificInstructions(clientApplication)}
## 3. EXECUTION
- **State Intent:** Briefly confirm: "Fetching [Topic] details from [Source]..."
- **Apply & Discard:** Extract only what is needed for the current task.
## ENFORCEMENT
⚠️ You MUST follow these rules. If you skip documentation lookup, you are breaking protocol.
`;
export const getPromptTemplate = async (
promptName: string,
extra: ToolHandlerExtraParams,
args?: Record<string, string>,
): Promise<string> => {
if (promptName === 'setup-neon-auth') {
// Variables are available for future template interpolation
void args?.projectId;
void args?.branchId;
void args?.databaseName;
const content = await fetchRawGithubContent(
'/neondatabase-labs/ai-rules/main/mcp-prompts/neon-auth-setup.md',
);
return `
${COMMON_FOLLOW_INSTRUCTIONS(extra.clientApplication)}
---
${content}`;
}
throw new Error(`Unknown prompt: ${promptName}`);
};
```
--------------------------------------------------------------------------------
/landing/lib/description.ts:
--------------------------------------------------------------------------------
```typescript
import { min } from 'lodash';
const POSSIBLE_TYPES = [
'use_case',
'workflow',
'important_notes',
'next_steps',
'response_instructions',
'instructions',
'example',
'do_not_include',
'error_handling',
'hint',
'hints',
] as const;
export type DescriptionItemType = (typeof POSSIBLE_TYPES)[number];
export type DescriptionItem =
| {
type: 'text';
content: TextBlock[];
}
| {
type: DescriptionItemType;
content: DescriptionItem[];
};
export type TextBlock =
| {
type: 'text';
content: TextSpan[];
}
| {
type: 'code';
syntax?: string;
content: string;
};
export type TextSpan =
| {
type: 'text';
content: string;
}
| {
type: 'code';
content: string;
};
function isValidType(string: string): string is DescriptionItemType {
return POSSIBLE_TYPES.includes(string as DescriptionItemType);
}
function highlightCodeSpans(text: string): TextSpan[] {
const items: TextSpan[] = [];
let rest = text.trim();
while (rest.length > 0) {
const match = rest.match(/`([^`]*)?`/);
if (!match) {
items.push({
type: 'text',
content: rest,
});
break;
}
if ((match.index ?? 0) !== 0) {
items.push({
type: 'text',
content: rest.substring(0, match.index),
});
}
items.push({
type: 'code',
content: match[1].trim(),
});
rest = rest.substring((match.index ?? 0) + match[0].length);
}
return items;
}
function removeRedundantIndentation(text: string): string {
const lines = text.split('\n');
const minIndent = min(
lines.map((line) => line.match(/^\s+/)?.[0].length ?? 0),
);
if (!minIndent) {
return text;
}
return lines.map((line) => line.substring(minIndent)).join('\n');
}
function highlightCodeBlocks(description: string): TextBlock[] {
const parts: TextBlock[] = [];
let rest = description.trim();
while (rest.length > 0) {
const match = rest.match(/```([^\n]*?)\n(.*?)\n\s*?```/s);
if (!match) {
parts.push({
type: 'text',
content: highlightCodeSpans(rest),
});
break;
}
if ((match.index ?? 0) > 0) {
parts.push({
type: 'text',
content: highlightCodeSpans(rest.slice(0, match.index).trim()),
});
}
parts.push({
type: 'code',
syntax: match[1].trim() || undefined,
content: removeRedundantIndentation(match[2]),
});
rest = rest.substring((match.index ?? 0) + match[0].length).trim();
}
return parts;
}
export function parseDescription(description: string): DescriptionItem[] {
const parts: DescriptionItem[] = [];
let rest = description.trim();
while (rest.length > 0) {
const match = rest.match(
/<(use_case|workflow|important_notes|next_steps|response_instructions|instructions|example|do_not_include|error_handling|hints?)>(.*?)<\/\1>/s,
);
if (!match) {
parts.push({
type: 'text',
content: highlightCodeBlocks(rest),
});
break;
}
const type = match[1];
if (!isValidType(type)) {
throw new Error('Invalid type');
}
if ((match.index ?? 0) > 0) {
parts.push({
type: 'text',
content: highlightCodeBlocks(rest.slice(0, match.index).trim()),
});
}
parts.push({
type,
content: parseDescription(match[2].trim()),
});
rest = rest.substring((match.index ?? 0) + match[0].length).trim();
}
return parts;
}
```
--------------------------------------------------------------------------------
/src/initConfig.ts:
--------------------------------------------------------------------------------
```typescript
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import chalk from 'chalk';
import { fileURLToPath } from 'url';
import { logger } from './utils/logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
);
// Determine Claude config path based on OS platform
let claudeConfigPath: string;
const platform = os.platform();
if (platform === 'win32') {
// Windows path - using %APPDATA%
// For Node.js, we access %APPDATA% via process.env.APPDATA
claudeConfigPath = path.join(
process.env.APPDATA || '',
'Claude',
'claude_desktop_config.json',
);
} else {
// macOS and Linux path (according to official docs)
claudeConfigPath = path.join(
os.homedir(),
'Library',
'Application Support',
'Claude',
'claude_desktop_config.json',
);
}
const MCP_NEON_SERVER = 'neon';
type Args =
| {
command: 'start:sse';
analytics: boolean;
}
| {
command: 'start';
neonApiKey: string;
analytics: boolean;
}
| {
command: 'init';
executablePath: string;
neonApiKey: string;
analytics: boolean;
}
| {
command: 'export-tools';
};
const commands = ['init', 'start', 'start:sse', 'export-tools'] as const;
export const parseArgs = (): Args => {
const args = process.argv;
if (args.length < 3) {
logger.error('Invalid number of arguments');
process.exit(1);
}
if (args.length === 3 && args[2] === 'start:sse') {
return {
command: 'start:sse',
analytics: true,
};
}
if (args.length === 3 && args[2] === 'export-tools') {
return {
command: 'export-tools',
};
}
const command = args[2];
if (!commands.includes(command as (typeof commands)[number])) {
logger.error(`Invalid command: ${command}`);
process.exit(1);
}
if (command === 'export-tools') {
return {
command: 'export-tools',
};
}
if (args.length < 4) {
logger.error(
'Please provide a NEON_API_KEY as a command-line argument - you can get one through the Neon console: https://neon.tech/docs/manage/api-keys',
);
process.exit(1);
}
return {
executablePath: args[1],
command: args[2] as 'start' | 'init',
neonApiKey: args[3],
analytics: !args[4]?.includes('no-analytics'),
};
};
export function handleInit({
executablePath,
neonApiKey,
analytics,
}: {
executablePath: string;
neonApiKey: string;
analytics: boolean;
}) {
// If the executable path is a local path to the dist/index.js file, use it directly
// Otherwise, use the name of the package to always load the latest version from remote
const serverPath = executablePath.includes('dist/index.js')
? executablePath
: packageJson.name;
const neonConfig = {
command: 'npx',
args: [
'-y',
serverPath,
'start',
neonApiKey,
analytics ? '' : '--no-analytics',
],
};
const configDir = path.dirname(claudeConfigPath);
if (!fs.existsSync(configDir)) {
console.log(chalk.blue('Creating Claude config directory...'));
fs.mkdirSync(configDir, { recursive: true });
}
const existingConfig = fs.existsSync(claudeConfigPath)
? JSON.parse(fs.readFileSync(claudeConfigPath, 'utf8'))
: { mcpServers: {} };
if (MCP_NEON_SERVER in (existingConfig?.mcpServers || {})) {
console.log(chalk.yellow('Replacing existing Neon MCP config...'));
}
const newConfig = {
...existingConfig,
mcpServers: {
...existingConfig.mcpServers,
[MCP_NEON_SERVER]: neonConfig,
},
};
fs.writeFileSync(claudeConfigPath, JSON.stringify(newConfig, null, 2));
console.log(chalk.green(`Config written to: ${claudeConfigPath}`));
console.log(
chalk.blue(
'The Neon MCP server will start automatically the next time you open Claude.',
),
);
}
```
--------------------------------------------------------------------------------
/src/transports/sse-express.ts:
--------------------------------------------------------------------------------
```typescript
import '../sentry/instrument.js';
import { setupExpressErrorHandler } from '@sentry/node';
import express, { Request, Response, RequestHandler } from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { createMcpServer } from '../server/index.js';
import { logger, morganConfig, errorHandler } from '../utils/logger.js';
import { authRouter } from '../oauth/server.js';
import { SERVER_PORT, SERVER_HOST } from '../constants.js';
import { ensureCorsHeaders, requiresAuth } from '../oauth/utils.js';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import { track } from '../analytics/analytics.js';
import { AppContext } from '../types/context.js';
import { createStreamTransport } from './stream.js';
export const createSseTransport = (appContext: AppContext) => {
const app = express();
app.use(morganConfig);
app.use(errorHandler);
app.use(cookieParser());
app.use(ensureCorsHeaders());
app.use(express.static('public'));
app.set('view engine', 'pug');
app.set('views', 'src/views');
const streamHandler = createStreamTransport({
...appContext,
transport: 'stream',
});
app.use('/mcp', streamHandler);
app.use('/', authRouter);
// to support multiple simultaneous connections we have a lookup object from
// sessionId to transport
const transports = new Map<string, SSEServerTransport>();
app.get(
'/sse',
bodyParser.raw(),
requiresAuth(),
async (req: Request, res: Response) => {
const auth = req.auth;
if (!auth) {
res.status(401).send('Unauthorized');
return;
}
const transport = new SSEServerTransport('/messages', res);
transports.set(transport.sessionId, transport);
logger.info('new sse connection', {
sessionId: transport.sessionId,
});
res.on('close', () => {
logger.info('SSE connection closed', {
sessionId: transport.sessionId,
});
transports.delete(transport.sessionId);
});
try {
const server = createMcpServer({
apiKey: auth.token,
client: auth.extra.client,
account: auth.extra.account,
app: appContext,
readOnly: auth.extra.readOnly,
userAgent: req.headers['user-agent'],
});
await server.connect(transport);
} catch (error: unknown) {
logger.error('Failed to connect to MCP server:', {
message: error instanceof Error ? error.message : 'Unknown error',
error,
});
track({
userId: auth.extra.account.id,
event: 'sse_connection_errored',
properties: { error },
context: {
app: appContext,
client: auth.extra.client,
},
});
}
},
);
app.post('/messages', bodyParser.raw(), requiresAuth(), (async (
request: Request,
response: Response,
) => {
const auth = request.auth;
if (!auth) {
response.status(401).send('Unauthorized');
return;
}
const sessionId = request.query.sessionId as string;
const transport = transports.get(sessionId);
logger.info('transport message received', {
sessionId,
hasTransport: Boolean(transport),
});
try {
if (transport) {
await transport.handlePostMessage(request, response);
} else {
logger.warn('No transport found for sessionId', { sessionId });
response.status(400).send('No transport found for sessionId');
}
} catch (error: unknown) {
logger.error('Failed to handle post message:', {
message: error instanceof Error ? error.message : 'Unknown error',
error,
});
track({
userId: auth.extra.account.id,
event: 'transport_message_errored',
properties: { error },
context: { app: appContext, client: auth.extra.client },
});
}
}) as RequestHandler);
setupExpressErrorHandler(app);
try {
app.listen({ port: SERVER_PORT });
logger.info(`Server started on ${SERVER_HOST}`);
} catch (err: unknown) {
logger.error('Failed to start server:', {
error: err instanceof Error ? err.message : 'Unknown error',
});
process.exit(1);
}
};
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
# Changelog
# [Unreleased]
- Feat: `compare_database_schema` tool to generate schema diff for a specific branch. With prompt to generate migration script
- Feat: `neon-get-started` resource to add AI rules with steps and instructions to integrate projects with Neon
- Feat: Add generic `search` and `fetch` tools for organizations, projects, and branches
# [0.6.5] 2025-09-16
- 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
- docs: add copyable server link on langing page
# [0.6.4] 2025-08-22
- Fix: Do not log user sensitive information on errors
- Fix: Return non-500 errors as valid response with `isError=true` without logging
- Improvement: Custom error handling user generated erorrs
- Improvement: Extend org-only users search to support orgs not managed by console.
# [0.6.3] 2025-08-04
- Feat: A new tool to list authenitcated user's organizations - `list_organizations`
- Docs: Switch configs to use streamable HTTP by default
- Impr: While searching for project in `list_projects` tool, extend the search to all organizations.
## [0.6.2] 2025-07-17
- Add warnings on security risks involved in MCP tools in production environments
- Migrate the deployment to Koyeb
- Mark `param` as required argument for all tools
## [0.6.1] 2025-06-19
- Documentation: Updated README with new tools and features
- Support API key authentication for remote server
## [0.6.0] 2025-06-16
- Fix: Issue with ORG API keys in local mode
- Refc: Tools into smaller manageable modules
- Feat: New landing page with details of supported tools
- Feat: Streamable HTTP support
## [0.5.0] 2025-05-28
- Tracking tool calls and errors with Segment
- Capture exections with Sentry
- Add tracing with sentry
- Support new org-only accounts
## [0.4.1] - 2025-05-08
- fix the `npx start` command to start server in stdio transport mode
- fix issue with unexpected tokens in stdio transport mode
## [0.4.0] - 2025-05-08
- Feature: Support for remote MCP with OAuth flow.
- Remove `__node_version` tool
- Feature: Add `list_slow_queries` tool for monitoring database performance
- Add `list_branch_computes` tool to list compute endpoints for a project or specific branch
## [0.3.7] - 2025-04-23
- Fixes Neon Auth instructions to install latest version of the SDK
## [0.3.6] - 2025-04-20
- Bumps the Neon serverless driver to 1.0.0
## [0.3.5] - 2025-04-19
- Fix default database name or role name assumptions.
- Adds better error message for project creations.
## [0.3.4] - 2025-03-26
- Add `neon-auth`, `neon-serverless`, and `neon-drizzle` resources
- Fix initialization on Windows by implementing correct platform-specific paths for Claude configuration
## [0.3.3] - 2025-03-19
- Fix the API Host
## [0.3.2] - 2025-03-19
- Add User-Agent to api calls from mcp server
## [0.3.1] - 2025-03-19
- Add User-Agent to api calls from mcp server
## [0.3.0] - 2025-03-14
- Add `provision_neon_auth` tool
## [0.2.3] - 2025-03-06
- Adds `get_connection_string` tool
- Hints the LLM to call the `create_project` tool to create new databases
## [0.2.2] - 2025-02-26
- Fixed a bug in the `list_projects` tool when passing no params
- Added a `params` property to all the tools input schemas
## [0.2.1] - 2025-02-25
- Fixes a bug in the `list_projects` tool
- Update the `@modelcontextprotocol/sdk` to the latest version
- Use `zod` to validate tool input schemas
## [0.2.0] - 2025-02-24
- Add [Smithery](https://smithery.ai/server/neon) deployment config
## [0.1.9] - 2025-01-06
- Setups tests to the `prepare_database_migration` tool
- Updates the `prepare_database_migration` tool to be more deterministic
- Removes logging from the MCP server, following the [docs](https://modelcontextprotocol.io/docs/tools/debugging#implementing-logging)
## [0.1.8] - 2024-12-25
- Added `beforePublish` script so make sure the changelog is updated before publishing
- Makes the descriptions/prompts for the prepare_database_migration and complete_database_migration tools much better
## [0.1.7-beta.1] - 2024-12-19
- Added support for `prepare_database_migration` and `complete_database_migration` tools
```
--------------------------------------------------------------------------------
/src/describeUtils.ts:
--------------------------------------------------------------------------------
```typescript
/**
* This module is derived from @neondatabase/psql-describe
* Original source: https://github.com/neondatabase/psql-describe
*/
import { neon } from '@neondatabase/serverless';
export type TableDescription = {
columns: ColumnDescription[];
indexes: IndexDescription[];
constraints: ConstraintDescription[];
tableSize: string;
indexSize: string;
totalSize: string;
};
export type ColumnDescription = {
name: string;
type: string;
nullable: boolean;
default: string | null;
description: string | null;
};
export type IndexDescription = {
name: string;
definition: string;
size: string;
};
export type ConstraintDescription = {
name: string;
type: string;
definition: string;
};
export const DESCRIBE_TABLE_STATEMENTS = [
// Get column information
`
SELECT
c.column_name as name,
c.data_type as type,
c.is_nullable = 'YES' as nullable,
c.column_default as default,
pd.description
FROM information_schema.columns c
LEFT JOIN pg_catalog.pg_statio_all_tables st ON c.table_schema = st.schemaname AND c.table_name = st.relname
LEFT JOIN pg_catalog.pg_description pd ON pd.objoid = st.relid AND pd.objsubid = c.ordinal_position
WHERE c.table_schema = 'public' AND c.table_name = $1
ORDER BY c.ordinal_position;
`,
// Get index information
`
SELECT
i.relname as name,
pg_get_indexdef(i.oid) as definition,
pg_size_pretty(pg_relation_size(i.oid)) as size
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
WHERE t.relname = $1 AND t.relkind = 'r';
`,
// Get constraint information
`
SELECT
tc.constraint_name as name,
tc.constraint_type as type,
pg_get_constraintdef(cc.oid) as definition
FROM information_schema.table_constraints tc
JOIN pg_catalog.pg_constraint cc ON tc.constraint_name = cc.conname
WHERE tc.table_schema = 'public' AND tc.table_name = $1;
`,
// Get table size information
`
SELECT
pg_size_pretty(pg_total_relation_size($1)) as total_size,
pg_size_pretty(pg_relation_size($1)) as table_size,
pg_size_pretty(pg_total_relation_size($1) - pg_relation_size($1)) as index_size;
`,
];
export async function describeTable(
connectionString: string,
tableName: string,
): Promise<TableDescription> {
const sql = neon(connectionString);
// Execute all queries in parallel
const [columns, indexes, constraints, sizes] = await Promise.all([
sql.query(DESCRIBE_TABLE_STATEMENTS[0], [tableName]),
sql.query(DESCRIBE_TABLE_STATEMENTS[1], [tableName]),
sql.query(DESCRIBE_TABLE_STATEMENTS[2], [tableName]),
sql.query(DESCRIBE_TABLE_STATEMENTS[3], [tableName]),
]);
return {
columns: columns.map((col) => ({
name: col.name,
type: col.type,
nullable: col.nullable,
default: col.default,
description: col.description,
})),
indexes: indexes.map((idx) => ({
name: idx.name,
definition: idx.definition,
size: idx.size,
})),
constraints: constraints.map((con) => ({
name: con.name,
type: con.type,
definition: con.definition,
})),
tableSize: sizes[0].table_size,
indexSize: sizes[0].index_size,
totalSize: sizes[0].total_size,
};
}
export function formatTableDescription(desc: TableDescription): string {
const lines: string[] = [];
// Add table size information
lines.push(`Table size: ${desc.tableSize}`);
lines.push(`Index size: ${desc.indexSize}`);
lines.push(`Total size: ${desc.totalSize}`);
lines.push('');
// Add columns
lines.push('Columns:');
desc.columns.forEach((col) => {
const nullable = col.nullable ? 'NULL' : 'NOT NULL';
const defaultStr = col.default ? ` DEFAULT ${col.default}` : '';
const descStr = col.description ? `\n ${col.description}` : '';
lines.push(` ${col.name} ${col.type} ${nullable}${defaultStr}${descStr}`);
});
lines.push('');
// Add indexes
if (desc.indexes.length > 0) {
lines.push('Indexes:');
desc.indexes.forEach((idx) => {
lines.push(` ${idx.name} (${idx.size})`);
lines.push(` ${idx.definition}`);
});
lines.push('');
}
// Add constraints
if (desc.constraints.length > 0) {
lines.push('Constraints:');
desc.constraints.forEach((con) => {
lines.push(` ${con.name} (${con.type})`);
lines.push(` ${con.definition}`);
});
}
return lines.join('\n');
}
```
--------------------------------------------------------------------------------
/src/oauth/cookies.ts:
--------------------------------------------------------------------------------
```typescript
import {
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
const COOKIE_NAME = 'approved-mcp-clients';
const ONE_YEAR_IN_SECONDS = 365 * 24 * 60 * 60 * 1000; // 365 days
/**
* Imports a secret key string for HMAC-SHA256 signing.
* @param secret - The raw secret key string.
* @returns A promise resolving to the CryptoKey object.
*/
const importKey = async (secret: string): Promise<CryptoKey> => {
const enc = new TextEncoder();
return crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
);
};
/**
* Signs data using HMAC-SHA256.
* @param key - The CryptoKey for signing.
* @param data - The string data to sign.
* @returns A promise resolving to the signature as a hex string.
*/
const signData = async (key: CryptoKey, data: string): Promise<string> => {
const enc = new TextEncoder();
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
key,
enc.encode(data),
);
// Convert ArrayBuffer to hex string
return Array.from(new Uint8Array(signatureBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
/**
* Verifies an HMAC-SHA256 signature.
* @param key - The CryptoKey for verification.
* @param signatureHex - The signature to verify (hex string).
* @param data - The original data that was signed.
* @returns A promise resolving to true if the signature is valid, false otherwise.
*/
const verifySignature = async (
key: CryptoKey,
signatureHex: string,
data: string,
): Promise<boolean> => {
try {
// Convert hex signature back to ArrayBuffer
const enc = new TextEncoder();
const signatureBytes = new Uint8Array(
signatureHex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? [],
);
return await crypto.subtle.verify(
'HMAC',
key,
signatureBytes.buffer,
enc.encode(data),
);
} catch (e) {
// Handle errors during hex parsing or verification
console.error('Error verifying signature:', e);
return false;
}
};
/**
* Parses the signed cookie and verifies its integrity.
* @param cookieHeader - The value of the Cookie header from the request.
* @param secret - The secret key used for signing.
* @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null.
*/
const getApprovedClientsFromCookie = async (
cookie: string,
secret: string,
): Promise<string[]> => {
if (!cookie) return [];
try {
const [signatureHex, base64Payload] = cookie.split('.');
if (!signatureHex || !base64Payload) return [];
const payload = atob(base64Payload);
const key = await importKey(secret);
const isValid = await verifySignature(key, signatureHex, payload);
if (!isValid) return [];
const clients = JSON.parse(payload);
return Array.isArray(clients) ? clients : [];
} catch {
return [];
}
};
/**
* Checks if a given client has already been approved by the user,
* based on a signed cookie.
*
* @param request - The incoming Request object to read cookies from.
* @param clientId - The OAuth client ID to check approval for.
* @param cookieSecret - The secret key used to sign/verify the approval cookie.
* @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise.
*/
export const isClientAlreadyApproved = async (
req: ExpressRequest,
clientId: string,
cookieSecret: string,
) => {
const approvedClients = await getApprovedClientsFromCookie(
req.cookies[COOKIE_NAME] ?? '',
cookieSecret,
);
return approvedClients.includes(clientId);
};
/**
* Updates the approved clients cookie with a new client ID.
* The cookie is signed using HMAC-SHA256 for integrity.
*
* @param request - Express request containing existing cookie
* @param clientId - Client ID to add to approved list
* @param cookieSecret - Secret key for signing cookie
* @returns Cookie string with updated approved clients list
*/
export const updateApprovedClientsCookie = async (
req: ExpressRequest,
res: ExpressResponse,
clientId: string,
cookieSecret: string,
) => {
const approvedClients = await getApprovedClientsFromCookie(
req.cookies[COOKIE_NAME] ?? '',
cookieSecret,
);
const newApprovedClients = JSON.stringify(
Array.from(new Set([...approvedClients, clientId])),
);
const key = await importKey(cookieSecret);
const signature = await signData(key, newApprovedClients);
res.cookie(COOKIE_NAME, `${signature}.${btoa(newApprovedClients)}`, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: ONE_YEAR_IN_SECONDS,
path: '/',
});
};
```
--------------------------------------------------------------------------------
/src/tools/handlers/search.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { Api, Organization, ProjectListItem } from '@neondatabase/api-client';
import { Branch } from '@neondatabase/api-client';
import { searchInputSchema } from '../toolsSchema.js';
import { z } from 'zod';
import { ToolHandlerExtraParams } from '../types.js';
import { handleListProjects } from './list-projects.js';
import { CONSOLE_URLS, generateConsoleUrl } from './urls.js';
type SearchProps = z.infer<typeof searchInputSchema>;
type MCPOrgId = `org:${string}`;
type MCPProjectId = `project:${string}`;
type MCPBranchId = `branch:${string}/${string}`; // projectId/branchId
type SearchResultId = MCPOrgId | MCPProjectId | MCPBranchId;
type SearchResult = {
id: SearchResultId;
title: string;
url: string;
type: 'organization' | 'project' | 'branch';
};
const matches = (
entity: Organization | ProjectListItem | Branch,
query: string,
) => {
return (
entity.name.toLowerCase().includes(query) ||
entity.id.toLowerCase().includes(query)
);
};
export async function handleSearch(
{ query }: SearchProps,
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
): Promise<CallToolResult> {
try {
const results: SearchResult[] = [];
const searchQuery = query.toLowerCase();
// Search through all user's organizations
let organizations;
if (extra.account.isOrg) {
const orgId = extra.account.id;
const { data } = await neonClient.getOrganization(orgId);
organizations = [data];
} else {
const { data: response } = await neonClient.getCurrentUserOrganizations();
organizations = response.organizations || [];
}
// If in personal account, search projects
if (!extra.account.isOrg) {
const projects = await handleListProjects(
{
limit: 400,
},
neonClient,
extra,
);
const searchResults = await searchProjectsAndBranches(
projects,
neonClient,
searchQuery,
);
results.push(...searchResults);
}
// Search in all organizations
for (const org of organizations) {
// Check if organization matches the search query
if (matches(org, searchQuery)) {
results.push({
id: `org:${org.id}`,
title: org.name,
url: generateConsoleUrl(CONSOLE_URLS.ORGANIZATION, {
orgId: org.id,
}),
type: 'organization',
});
}
try {
const projects = await handleListProjects(
{
org_id: org.id,
limit: 400,
},
neonClient,
extra,
);
const searchResults = await searchProjectsAndBranches(
projects,
neonClient,
searchQuery,
);
results.push(...searchResults);
} catch {
// Skip projects if we can't access them
continue;
}
}
// Also search shared projects
try {
const { data } = await neonClient.listSharedProjects({
limit: 400,
});
const searchResults = await searchProjectsAndBranches(
data.projects,
neonClient,
searchQuery,
);
results.push(...searchResults);
} catch {
// Skip shared projects if we can't access them
}
return {
content: [
{
type: 'text',
text: JSON.stringify(results, null, 2),
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to search: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
const searchProjectsAndBranches = async (
projects: ProjectListItem[],
neonClient: Api<unknown>,
query: string,
): Promise<SearchResult[]> => {
const results: SearchResult[] = [];
projects.forEach((project) => {
if (matches(project, query)) {
results.push({
id: `project:${project.id}`,
title: project.name,
url: generateConsoleUrl(CONSOLE_URLS.PROJECT, {
projectId: project.id,
}),
type: 'project',
});
}
});
const branches = await Promise.all(
projects.map(async (project) => {
return searchBranches(project.id, neonClient, query);
}),
);
results.push(...branches.flat());
return results;
};
const searchBranches = async (
projectId: string,
neonClient: Api<unknown>,
query: string,
): Promise<SearchResult[]> => {
try {
const { data } = await neonClient.listProjectBranches({
projectId,
});
const branches = data.branches;
return branches
.filter((branch) => matches(branch, query))
.map((branch) => ({
id: `branch:${projectId}/${branch.id}`,
title: branch.name,
url: generateConsoleUrl(CONSOLE_URLS.PROJECT_BRANCH, {
projectId,
branchId: branch.id,
}),
type: 'branch',
}));
} catch {
// Ignore if we fail to fetch branches
return [];
}
};
```
--------------------------------------------------------------------------------
/src/tools/handlers/fetch.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { Api, MemberWithUser, ProjectListItem } from '@neondatabase/api-client';
import { fetchInputSchema } from '../toolsSchema.js';
import { z } from 'zod';
import { handleDescribeProject } from './decribe-project.js';
import { handleDescribeBranch } from './describe-branch.js';
import { ToolHandlerExtraParams } from '../types.js';
import { generateConsoleUrl, CONSOLE_URLS } from './urls.js';
type FetchProps = z.infer<typeof fetchInputSchema>;
export async function handleFetch(
{ id }: FetchProps,
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
): Promise<CallToolResult> {
try {
// Parse the ID format
if (id.startsWith('org:') || id.startsWith('org-')) {
return await fetchOrganizationDetails(id.slice(4), neonClient);
} else if (id.startsWith('branch:')) {
const [projectId, branchId] = id.slice(7).split('/');
return await fetchBranchDetails(projectId, branchId, neonClient, extra);
} else if (id.startsWith('project:')) {
return await fetchProjectDetails(id.slice(8), neonClient);
} else {
return {
isError: true,
content: [
{
type: 'text',
text: `Invalid ID format: "${id}". Expected format: org:*, project:*, or branch:*`,
},
],
};
}
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to fetch details: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
async function fetchOrganizationDetails(
orgId: string,
neonClient: Api<unknown>,
): Promise<CallToolResult> {
try {
const { data: orgData } = await neonClient.getOrganization(orgId);
let members: MemberWithUser[] = [];
try {
const { data: membersData } =
await neonClient.getOrganizationMembers(orgId);
members = membersData.members || [];
} catch {
// Skip if we can't access members
}
// Get projects count in this organization
let projects: ProjectListItem[] = [];
try {
const { data: projectsData } = await neonClient.listProjects({
org_id: orgId,
});
projects = projectsData.projects || [];
} catch {
// Skip if we can't access projects
}
const details = {
organization: {
id: orgData.id,
name: orgData.name,
created_at: orgData.created_at,
updated_at: orgData.updated_at,
},
console_url: generateConsoleUrl(CONSOLE_URLS.ORGANIZATION, {
orgId: orgData.id,
}),
};
return {
content: [
{
type: 'text',
text: `**Organization Details**
**Basic Information:**
- Name: ${details.organization.name}
- ID: ${details.organization.id}
- Created: ${new Date(details.organization.created_at).toLocaleDateString()}
**Statistics:**
${members.length > 0 ? `- Members: ${members.length}` : undefined}
- Projects: ${projects.length}
`,
metadata: {
org: orgData,
members: members,
projects: projects,
},
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to fetch organization details: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
async function fetchProjectDetails(
projectId: string,
neonClient: Api<unknown>,
): Promise<CallToolResult> {
try {
const { project, branches } = await handleDescribeProject(
projectId,
neonClient,
);
const defaultBranch = branches.find((branch) => branch.default);
return {
content: [
{
type: 'text',
text: `**Project Details**
**Basic Information:**
- Name: ${project.name}
- ID: ${project.id}
- Region: ${project.region_id}
- PostgreSQL Version: ${project.pg_version}
- Created: ${new Date(project.created_at).toLocaleDateString()}
- Last Updated: ${new Date(project.updated_at).toLocaleDateString()}
**Statistics:**
- Branches: ${branches.length}
- Default Branch: ${defaultBranch?.name} (${defaultBranch?.id})
`,
metadata: {
project: project,
branches: branches,
},
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to fetch project details: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
async function fetchBranchDetails(
projectId: string,
branchId: string,
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
): Promise<CallToolResult> {
try {
const result = await handleDescribeBranch(
{
projectId,
branchId,
},
neonClient,
extra,
);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to fetch branch details: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
```
--------------------------------------------------------------------------------
/landing/app/globals.css:
--------------------------------------------------------------------------------
```css
@import 'tailwindcss';
@import 'tw-animate-css';
/* @custom-variant dark (&:is(.dark *)); */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* user defined */
--color-important-notes: var(--important-notes);
--color-link: var(--link);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
/* user defined */
--important-notes: var(--color-orange-800);
--link: oklch(0.64 0.14 160.38);
}
@variant dark {
:root {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
/* user defined */
--important-notes: var(--color-orange-100);
--link: oklch(0.81 0.18 160.37);
}
}
@utility monospaced {
@apply font-[family-name:var(--font-geist-mono)];
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
/*font-family: Arial, Helvetica, sans-serif;*/
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}
@layer page {
.external-link {
@apply text-link;
@apply font-semibold;
@apply border-b;
@apply border-transparent;
@apply hover:border-current;
}
}
```
--------------------------------------------------------------------------------
/mcp-client/src/index.ts:
--------------------------------------------------------------------------------
```typescript
import { Anthropic } from '@anthropic-ai/sdk';
import {
StdioClientTransport,
StdioServerParameters,
} from '@modelcontextprotocol/sdk/client/stdio.js';
import {
ListToolsResultSchema,
CallToolResultSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import chalk from 'chalk';
import { Tool } from '@anthropic-ai/sdk/resources/index.mjs';
import { Stream } from '@anthropic-ai/sdk/streaming.mjs';
import { consoleStyles, Logger, LoggerOptions } from './logger.js';
interface Message {
role: 'user' | 'assistant';
content: string;
}
type MCPClientOptions = StdioServerParameters & {
loggerOptions?: LoggerOptions;
};
export class MCPClient {
private anthropicClient: Anthropic;
private messages: Message[] = [];
private mcpClient: Client;
private transport: StdioClientTransport;
private tools: Tool[] = [];
private logger: Logger;
constructor({ loggerOptions, ...serverConfig }: MCPClientOptions) {
this.anthropicClient = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
this.mcpClient = new Client(
{ name: 'cli-client', version: '1.0.0' },
{ capabilities: {} },
);
this.transport = new StdioClientTransport(serverConfig);
this.logger = new Logger(loggerOptions ?? { mode: 'verbose' });
}
async start() {
try {
await this.mcpClient.connect(this.transport);
await this.initMCPTools();
} catch (error) {
this.logger.log('Failed to initialize MCP Client: ' + error + '\n', {
type: 'error',
});
process.exit(1);
}
}
async stop() {
await this.mcpClient.close();
}
private async initMCPTools() {
const toolsResults = await this.mcpClient.request(
{ method: 'tools/list' },
ListToolsResultSchema,
);
this.tools = toolsResults.tools.map(({ inputSchema, ...tool }) => ({
...tool,
input_schema: inputSchema,
}));
}
private formatToolCall(toolName: string, args: any): string {
return (
'\n' +
consoleStyles.tool.bracket('[') +
consoleStyles.tool.name(toolName) +
consoleStyles.tool.bracket('] ') +
consoleStyles.tool.args(JSON.stringify(args, null, 2)) +
'\n'
);
}
private formatJSON(json: string): string {
return json
.replace(/"([^"]+)":/g, chalk.blue('"$1":'))
.replace(/: "([^"]+)"/g, ': ' + chalk.green('"$1"'));
}
private async processStream(
stream: Stream<Anthropic.Messages.RawMessageStreamEvent>,
): Promise<void> {
let currentMessage = '';
let currentToolName = '';
let currentToolInputString = '';
this.logger.log(consoleStyles.assistant);
for await (const chunk of stream) {
switch (chunk.type) {
case 'message_start':
case 'content_block_stop':
continue;
case 'content_block_start':
if (chunk.content_block?.type === 'tool_use') {
currentToolName = chunk.content_block.name;
}
break;
case 'content_block_delta':
if (chunk.delta.type === 'text_delta') {
this.logger.log(chunk.delta.text);
currentMessage += chunk.delta.text;
} else if (chunk.delta.type === 'input_json_delta') {
if (currentToolName && chunk.delta.partial_json) {
currentToolInputString += chunk.delta.partial_json;
}
}
break;
case 'message_delta':
if (currentMessage) {
this.messages.push({
role: 'assistant',
content: currentMessage,
});
}
if (chunk.delta.stop_reason === 'tool_use') {
const toolArgs = currentToolInputString
? JSON.parse(currentToolInputString)
: {};
this.logger.log(
this.formatToolCall(currentToolName, toolArgs) + '\n',
);
const toolResult = await this.mcpClient.request(
{
method: 'tools/call',
params: {
name: currentToolName,
arguments: toolArgs,
},
},
CallToolResultSchema,
);
const formattedResult = this.formatJSON(
JSON.stringify(toolResult.content.flatMap((c) => c.text)),
);
this.messages.push({
role: 'user',
content: formattedResult,
});
const nextStream = await this.anthropicClient.messages.create({
messages: this.messages,
model: 'claude-3-5-sonnet-20241022',
max_tokens: 8192,
tools: this.tools,
stream: true,
});
await this.processStream(nextStream);
}
break;
case 'message_stop':
break;
default:
this.logger.log(`Unknown event type: ${JSON.stringify(chunk)}\n`, {
type: 'warning',
});
}
}
}
async processQuery(query: string) {
try {
this.messages.push({ role: 'user', content: query });
const stream = await this.anthropicClient.messages.create({
messages: this.messages,
model: 'claude-3-5-sonnet-20241022',
max_tokens: 8192,
tools: this.tools,
stream: true,
});
await this.processStream(stream);
return this.messages;
} catch (error) {
this.logger.log('\nError during query processing: ' + error + '\n', {
type: 'error',
});
if (error instanceof Error) {
this.logger.log(
consoleStyles.assistant +
'I apologize, but I encountered an error: ' +
error.message +
'\n',
);
}
}
}
}
```
--------------------------------------------------------------------------------
/src/oauth/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { NextFunction, Request, Response } from 'express';
import cors from 'cors';
import crypto from 'crypto';
import { model } from './model.js';
import { ApiKeyRecord, apiKeys } from './kv-store.js';
import { createNeonClient } from '../server/api.js';
import { identify } from '../analytics/analytics.js';
const READ_ONLY_HEADER = 'X-read-only';
export const ensureCorsHeaders = () =>
cors({
origin: true,
methods: '*',
allowedHeaders: `Authorization, Origin, Content-Type, Accept, ${READ_ONLY_HEADER}, *`,
});
const fetchAccountDetails = async (
accessToken: string,
): Promise<ApiKeyRecord | null> => {
const apiKeyRecord = await apiKeys.get(accessToken);
if (apiKeyRecord) {
return apiKeyRecord;
}
try {
const neonClient = createNeonClient(accessToken);
const { data: auth } = await neonClient.getAuthDetails();
if (auth.auth_method === 'api_key_org') {
const { data: org } = await neonClient.getOrganization(auth.account_id);
const record = {
apiKey: accessToken,
authMethod: auth.auth_method,
account: {
id: auth.account_id,
name: org.name,
isOrg: true,
},
};
identify(record.account, { context: { authMethod: record.authMethod } });
await apiKeys.set(accessToken, record);
return record;
}
const { data: user } = await neonClient.getCurrentUserInfo();
const record = {
apiKey: accessToken,
authMethod: auth.auth_method,
account: {
id: user.id,
name: user.name,
email: user.email,
isOrg: false,
},
};
identify(record.account, { context: { authMethod: record.authMethod } });
await apiKeys.set(accessToken, record);
return record;
} catch {
return null;
}
};
export const requiresAuth =
() => async (request: Request, response: Response, next: NextFunction) => {
const authorization = request.headers.authorization;
if (!authorization) {
response.status(401).json({ error: 'Unauthorized' });
return;
}
const accessToken = extractBearerToken(authorization);
// Check for X-Read-Only header
const readOnlyHeader = request.headers[READ_ONLY_HEADER.toLowerCase()];
const readOnly = readOnlyHeader === 'true' || readOnlyHeader === '1';
const token = await model.getAccessToken(accessToken);
if (token) {
if (!token.expires_at || token.expires_at < Date.now()) {
response.status(401).json({ error: 'Access token expired' });
return;
}
request.auth = {
token: token.accessToken,
clientId: token.client.id,
scopes: Array.isArray(token.scope)
? token.scope
: (token.scope?.split(' ') ?? []),
extra: {
account: {
id: token.user.id,
name: token.user.name,
email: token.user.email,
isOrg: false,
},
client: {
id: token.client.id,
name: token.client.client_name,
},
readOnly,
},
};
next();
return;
}
// If the token is not found, try to resolve the auth headers with Neon for other means of authentication.
const apiKeyRecord = await fetchAccountDetails(accessToken);
if (!apiKeyRecord) {
response.status(401).json({ error: 'Invalid access token' });
return;
}
request.auth = {
token: accessToken,
clientId: 'api-key',
scopes: ['*'],
extra: {
account: apiKeyRecord.account,
readOnly,
},
};
next();
return;
};
export type DownstreamAuthRequest = {
responseType: string;
clientId: string;
redirectUri: string;
scope: string[];
state: string;
codeChallenge?: string;
codeChallengeMethod?: string;
};
export const parseAuthRequest = (request: Request): DownstreamAuthRequest => {
const responseType = (request.query.response_type || '') as string;
const clientId = (request.query.client_id || '') as string;
const redirectUri = (request.query.redirect_uri || '') as string;
const scope = (request.query.scope || '') as string;
const state = (request.query.state || '') as string;
const codeChallenge = (request.query.code_challenge as string) || undefined;
const codeChallengeMethod = (request.query.code_challenge_method ||
'plain') as string;
return {
responseType,
clientId,
redirectUri,
scope: scope.split(' ').filter(Boolean),
state,
codeChallenge,
codeChallengeMethod,
};
};
export const decodeAuthParams = (state: string): DownstreamAuthRequest => {
const decoded = atob(state);
return JSON.parse(decoded);
};
export const generateRandomString = (length: number): string => {
const charset =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, (byte) => charset[byte % charset.length]).join('');
};
export const extractBearerToken = (authorizationHeader: string): string => {
if (!authorizationHeader) return '';
return authorizationHeader.replace(/^Bearer\s+/i, '');
};
export const extractClientCredentials = (request: Request) => {
const authorization = request.headers.authorization;
if (authorization?.startsWith('Basic ')) {
const credentials = atob(authorization.replace(/^Basic\s+/i, ''));
const [clientId, clientSecret] = credentials.split(':');
return { clientId, clientSecret };
}
return {
clientId: request.body.client_id,
clientSecret: request.body.client_secret,
};
};
export const toSeconds = (ms: number): number => {
return Math.floor(ms / 1000);
};
export const toMilliseconds = (seconds: number): number => {
return seconds * 1000;
};
export const verifyPKCE = (
codeChallenge: string,
codeChallengeMethod: string,
codeVerifier: string,
): boolean => {
if (!codeChallenge || !codeChallengeMethod || !codeVerifier) {
return false;
}
if (codeChallengeMethod === 'S256') {
const hash = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return codeChallenge === hash;
}
if (codeChallengeMethod === 'plain') {
return codeChallenge === codeVerifier;
}
return false;
};
```