├── .bun-version ├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── fly-deploy.yml │ ├── fly-preview.yml │ └── pr.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.fly ├── LICENSE ├── PUBLISH.md ├── README.md ├── bun.lock ├── eslint.config.js ├── fly.preview.toml ├── fly.toml ├── mcp-client ├── .env.example ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── bin.ts │ ├── cli-client.ts │ ├── index.ts │ ├── logger.ts │ └── neon-cli-client.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── public └── logo.png ├── scripts └── before-publish.ts ├── smithery.yaml ├── src ├── analytics │ └── analytics.ts ├── constants.ts ├── describeUtils.ts ├── handlers │ └── neon-auth.ts ├── index.ts ├── initConfig.ts ├── oauth │ ├── client.ts │ ├── cookies.ts │ ├── kv-store.ts │ ├── model.ts │ ├── server.ts │ └── utils.ts ├── resources.ts ├── sentry │ ├── instrument.ts │ └── utils.ts ├── server │ ├── api.ts │ └── index.ts ├── state.ts ├── tools-evaluations │ ├── evalUtils.ts │ └── prepare-database-migration.eval.ts ├── tools.ts ├── toolsSchema.ts ├── transports │ ├── sse-express.ts │ └── stdio.ts ├── types │ ├── auth.ts │ ├── context.ts │ └── express.d.ts ├── utils.ts ├── utils │ ├── logger.ts │ └── polyfills.ts └── views │ ├── approval-dialog.pug │ └── styles.css ├── tsconfig.json └── tsconfig.test.json /.bun-version: -------------------------------------------------------------------------------- 1 | 1.2.4 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | **/node_modules 3 | **/*.log 4 | **/.env 5 | **/dist 6 | **/build 7 | # VS Code history extension 8 | **/.history 9 | fly.toml 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ## BrainTrust 2 | BRAINTRUST_API_KEY= 3 | 4 | ## Neon BaaS team org api key 5 | NEON_API_KEY= 6 | 7 | ## Anthropic api key to run the evals 8 | ANTHROPIC_API_KEY= 9 | 10 | ## Neon API 11 | NEON_API_HOST=https://api.neon.tech/api/v2 12 | ## OAuth upstream oauth host 13 | UPSTREAM_OAUTH_HOST='https://oauth2.neon.tech'; 14 | 15 | ## OAuth client id 16 | CLIENT_ID= 17 | ## OAuth client secret 18 | CLIENT_SECRET= 19 | 20 | ## Redirect URI for OIDC callback 21 | REDIRECT_URI=http://localhost:3001/callback 22 | 23 | ## A connection string to postgres database for client and token persistence 24 | ## Optional while running in MCP in stdio 25 | OAUTH_DATABASE_URL= 26 | 27 | ## A secret key to sign and verify the cookies 28 | ## Optional while running MCP in stdio 29 | COOKIE_SECRET= 30 | 31 | ## Optional Analytics 32 | ANALYTICS_WRITE_KEY= 33 | 34 | ## Optional Sentry 35 | SENTRY_DSN= -------------------------------------------------------------------------------- /.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | deploy: 11 | name: Deploy app 12 | runs-on: ubuntu-latest 13 | concurrency: deploy-group # optional: ensure only one action runs at a time 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: superfly/flyctl-actions/setup-flyctl@master 17 | - run: flyctl deploy --remote-only 18 | env: 19 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/fly-preview.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Preview App 2 | on: 3 | # Run this workflow on every PR event. Existing review apps will be updated when the PR is updated. 4 | pull_request: 5 | types: [opened, reopened, synchronize, closed, labeled] 6 | 7 | jobs: 8 | preview: 9 | if: contains(github.event.pull_request.labels.*.name, 'deploy-preview') 10 | name: Deploy Preview 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl secrets set --app preview-mcp-server-neon CLIENT_ID=${{secrets.CLIENT_ID}} CLIENT_SECRET=${{secrets.CLIENT_SECRET}} OAUTH_DATABASE_URL=${{secrets.PREVIEW_OAUTH_DATABASE_URL}} SERVER_HOST=${{vars.PREVIEW_SERVER_HOST}} NEON_API_HOST=${{vars.NEON_API_HOST_STAGING}} UPSTREAM_OAUTH_HOST=${{vars.OAUTH_HOST_STAGING}} COOKIE_SECRET=${{secrets.COOKIE_SECRET}} 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_PREVIEW_APP_TOKEN }} 19 | - run: flyctl deploy --remote-only --config ./fly.preview.toml 20 | env: 21 | FLY_API_TOKEN: ${{ secrets.FLY_PREVIEW_APP_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint-and-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | - name: Setup Node 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version-file: .nvmrc 18 | - name: Setup Bun 19 | uses: oven-sh/setup-bun@v2 20 | with: 21 | bun-version-file: .bun-version 22 | - name: Install 23 | run: bun install --frozen-lockfile 24 | - name: Lint 25 | run: bun run lint 26 | - name: Build 27 | run: bun run build 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | .env 4 | dist/ 5 | build/ 6 | # VS Code history extension 7 | .history -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.0.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - Tracking tool calls and errors with Segment 6 | - Capture exections with Sentry 7 | - Add tracing with sentry 8 | - Support new org-only accounts 9 | 10 | ## [0.4.1] - 2025-05-08 11 | 12 | - fix the `npx start` command to start server in stdio transport mode 13 | - fix issue with unexpected tokens in stdio transport mode 14 | 15 | ## [0.4.0] - 2025-05-08 16 | 17 | - Feature: Support for remote MCP with OAuth flow. 18 | - Remove `__node_version` tool 19 | - Feature: Add `list_slow_queries` tool for monitoring database performance 20 | - Add `list_branch_computes` tool to list compute endpoints for a project or specific branch 21 | 22 | ## [0.3.7] - 2025-04-23 23 | 24 | - Fixes Neon Auth instructions to install latest version of the SDK 25 | 26 | ## [0.3.6] - 2025-04-20 27 | 28 | - Bumps the Neon serverless driver to 1.0.0 29 | 30 | ## [0.3.5] - 2025-04-19 31 | 32 | - Fix default database name or role name assumptions. 33 | - Adds better error message for project creations. 34 | 35 | ## [0.3.4] - 2025-03-26 36 | 37 | - Add `neon-auth`, `neon-serverless`, and `neon-drizzle` resources 38 | - Fix initialization on Windows by implementing correct platform-specific paths for Claude configuration 39 | 40 | ## [0.3.3] - 2025-03-19 41 | 42 | - Fix the API Host 43 | 44 | ## [0.3.2] - 2025-03-19 45 | 46 | - Add User-Agent to api calls from mcp server 47 | 48 | ## [0.3.1] - 2025-03-19 49 | 50 | - Add User-Agent to api calls from mcp server 51 | 52 | ## [0.3.0] - 2025-03-14 53 | 54 | - Add `provision_neon_auth` tool 55 | 56 | ## [0.2.3] - 2025-03-06 57 | 58 | - Adds `get_connection_string` tool 59 | - Hints the LLM to call the `create_project` tool to create new databases 60 | 61 | ## [0.2.2] - 2025-02-26 62 | 63 | - Fixed a bug in the `list_projects` tool when passing no params 64 | - Added a `params` property to all the tools input schemas 65 | 66 | ## [0.2.1] - 2025-02-25 67 | 68 | - Fixes a bug in the `list_projects` tool 69 | - Update the `@modelcontextprotocol/sdk` to the latest version 70 | - Use `zod` to validate tool input schemas 71 | 72 | ## [0.2.0] - 2025-02-24 73 | 74 | - Add [Smithery](https://smithery.ai/server/neon) deployment config 75 | 76 | ## [0.1.9] - 2025-01-06 77 | 78 | - Setups tests to the `prepare_database_migration` tool 79 | - Updates the `prepare_database_migration` tool to be more deterministic 80 | - Removes logging from the MCP server, following the [docs](https://modelcontextprotocol.io/docs/tools/debugging#implementing-logging) 81 | 82 | ## [0.1.8] - 2024-12-25 83 | 84 | - Added `beforePublish` script so make sure the changelog is updated before publishing 85 | - Makes the descriptions/prompts for the prepare_database_migration and complete_database_migration tools much better 86 | 87 | ## [0.1.7-beta.1] - 2024-12-19 88 | 89 | - Added support for `prepare_database_migration` and `complete_database_migration` tools 90 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | # Use the imbios/bun-node image as the base image with Node and Bun 3 | # Keep bun and node version in sync with package.json 4 | FROM imbios/bun-node:1.1.38-18-alpine AS builder 5 | 6 | # Set the working directory in the container 7 | WORKDIR /app 8 | 9 | # Copy package.json and package-lock.json 10 | COPY package.json package-lock.json ./ 11 | 12 | # Copy the entire project to the working directory 13 | COPY . . 14 | 15 | # Install the dependencies and devDependencies 16 | RUN npm install 17 | 18 | # Build the project 19 | RUN npm run build 20 | 21 | # Use a smaller base image for the final image 22 | FROM node:18-alpine AS release 23 | 24 | # Set the working directory 25 | WORKDIR /app 26 | 27 | # Copy only the necessary files from the builder stage 28 | COPY --from=builder /app/dist /app/dist 29 | COPY --from=builder /app/package.json /app/package.json 30 | COPY --from=builder /app/package-lock.json /app/package-lock.json 31 | 32 | # Install only production dependencies 33 | RUN npm ci --omit=dev 34 | 35 | # Define environment variables 36 | ENV NODE_ENV=production 37 | 38 | # Specify the command to run the MCP server 39 | ENTRYPOINT ["node", "dist/index.js", "start", "$NEON_API_KEY"] 40 | -------------------------------------------------------------------------------- /Dockerfile.fly: -------------------------------------------------------------------------------- 1 | 2 | # Use the imbios/bun-node image as the base image with Node and Bun 3 | # Keep bun and node version in sync with package.json 4 | ARG NODE_VERSION=22.0.0 5 | ARG BUN_VERSION=1.1.38 6 | FROM imbios/bun-node:1.1.38-22-slim AS base 7 | 8 | LABEL fly_launch_runtime="Node.js" 9 | 10 | # Set the working directory in the container 11 | WORKDIR /app 12 | 13 | # Set production environment 14 | ENV NODE_ENV="production" 15 | 16 | # Throw-away build stage to reduce size of final image 17 | From base As builder 18 | 19 | # Install packages needed to build node modules 20 | RUN apt-get update -qq && \ 21 | apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 22 | 23 | # Copy package.json and package-lock.json 24 | COPY package.json package-lock.json ./ 25 | 26 | # Copy the entire project to the working directory 27 | COPY . . 28 | 29 | # Install the dependencies and devDependencies 30 | RUN npm ci --include=dev 31 | 32 | # Build the project 33 | RUN npm run build 34 | 35 | # Remove development dependencies 36 | RUN npm prune --omit=dev 37 | 38 | # Final stage for app image 39 | FROM base 40 | 41 | # Copy built application 42 | COPY --from=builder /app /app 43 | 44 | 45 | # Define environment variables 46 | ENV NODE_ENV=production 47 | 48 | EXPOSE 3001 49 | # Specify the command to run the MCP server 50 | CMD ["node", "dist/index.js", "start:sse"] 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 - 2024 Neon Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PUBLISH.md: -------------------------------------------------------------------------------- 1 | ## Publish 2 | 3 | ### New release 4 | 5 | ```bash 6 | npm run build 7 | npm version patch|minor|major 8 | npm publish 9 | ``` 10 | 11 | ### New Beta Release 12 | 13 | ```bash 14 | npm run build 15 | npm version prerelease --preid=beta 16 | npm publish --tag beta 17 | ``` 18 | 19 | ### Promote beta to release 20 | 21 | ```bash 22 | npm version patch 23 | npm publish 24 | ``` 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Neon MCP Server 4 | 5 | **Neon MCP Server** is an open-source tool that lets you interact with your Neon Postgres databases in **natural language**. 6 | 7 | [![npm version](https://img.shields.io/npm/v/@neondatabase/mcp-server-neon)](https://www.npmjs.com/package/@neondatabase/mcp-server-neon) 8 | [![npm downloads](https://img.shields.io/npm/dt/@neondatabase/mcp-server-neon)](https://www.npmjs.com/package/@neondatabase/mcp-server-neon) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 10 | [![smithery badge](https://smithery.ai/badge/neon)](https://smithery.ai/server/neon) 11 | 12 | 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). 13 | 14 | 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. 15 | 16 | Some of the key features of the Neon MCP server include: 17 | 18 | - **Natural language interaction:** Manage Neon databases using intuitive, conversational commands. 19 | - **Simplified database management:** Perform complex actions without writing SQL or directly using the Neon API. 20 | - **Accessibility for non-developers:** Empower users with varying technical backgrounds to interact with Neon databases. 21 | - **Database migration support:** Leverage Neon's branching capabilities for database schema changes initiated via natural language. 22 | 23 | For example, in Claude Desktop, or any MCP Client, you can use natural language to accomplish things with Neon, such as: 24 | 25 | - `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.` 26 | - `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".` 27 | - `Can you give me a summary of all of my Neon projects and what data is in each one?` 28 | 29 | > [!NOTE] 30 | > 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 and Neon API keys. 31 | 32 | ## Setting up Neon MCP Server 33 | 34 | You have two options for connecting your MCP client to Neon: 35 | 36 | 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. 37 | 38 | 2. **Local MCP Server:** Run the Neon MCP server locally on your machine, authenticating with a Neon API key. 39 | 40 | ## Prerequisites 41 | 42 | - An MCP Client application. 43 | - A [Neon account](https://console.neon.tech/signup). 44 | - **Node.js (>= v18.0.0) and npm:** Download from [nodejs.org](https://nodejs.org). 45 | 46 | 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. 47 | 48 | ### Option 1. Remote Hosted MCP Server (Preview) 49 | 50 | 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. 51 | 52 | - Add the following "Neon" entry to your client's MCP server configuration file (e.g., `mcp.json`, `mcp_config.json`): 53 | 54 | ```json 55 | { 56 | "mcpServers": { 57 | "Neon": { 58 | "command": "npx", 59 | "args": ["-y", "mcp-remote", "https://mcp.neon.tech/sse"] 60 | } 61 | } 62 | } 63 | ``` 64 | 65 | - Save the configuration file. 66 | - Restart or refresh your MCP client. 67 | - An OAuth window will open in your browser. Follow the prompts to authorize your MCP client to access your Neon account. 68 | 69 | ### Option 2. Local MCP Server 70 | 71 | Run the Neon MCP server on your local machine. 72 | 73 | **Setup via Smithery:** 74 | 75 | ```bash 76 | npx -y @smithery/cli@latest install neon --client 77 | ``` 78 | 79 | You will be prompted to enter your Neon API key. Enter the API key which you obtained from the [prerequisites](#prerequisites) section 80 | Replace `` with the name of your MCP client application. Supported client names include: 81 | 82 | - `claude` for [Claude Desktop](https://claude.ai/download) 83 | - `cursor` for [Cursor](https://cursor.com) (Installing via `smithery` makes the MCP server a global MCP server in Cursor) 84 | - `windsurf` for [Windsurf Editor](https://codeium.com/windsurf) 85 | - `roo-cline` for [Roo Cline VS Code extension](https://github.com/RooVetGit/Roo-Code) 86 | - `witsy` for [Witsy](https://witsyai.com/) 87 | - `enconvo` for [Enconvo](https://www.enconvo.com/) 88 | - `vscode` for [Visual Studio Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) 89 | 90 | Restart your MCP client after installation. 91 | 92 | **Setup via npm** 93 | 94 | If your MCP client is not listed here, you can manually add the Neon MCP Server details to your client's `mcp_config` file. 95 | 96 | Add the following JSON configuration within the `mcpServers` section of your client's `mcp_config` file, replacing `` with your actual Neon API key: 97 | 98 | ```json 99 | { 100 | "mcpServers": { 101 | "neon": { 102 | "command": "npx", 103 | "args": [ 104 | "-y", 105 | "@neondatabase/mcp-server-neon", 106 | "start", 107 | "" 108 | ] 109 | } 110 | } 111 | } 112 | ``` 113 | 114 | ### Troubleshooting 115 | 116 | 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: 117 | 118 | ```bash 119 | npx -y @neondatabase/mcp-server-neon start 120 | ``` 121 | 122 | #### Troubleshooting on Windows 123 | 124 | 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: 125 | 126 | ```json 127 | { 128 | "mcpServers": { 129 | "neon": { 130 | "command": "cmd", 131 | "args": [ 132 | "/c", 133 | "npx", 134 | "-y", 135 | "@neondatabase/mcp-server-neon", 136 | "start", 137 | "" 138 | ] 139 | } 140 | } 141 | } 142 | ``` 143 | 144 | ```json 145 | { 146 | "mcpServers": { 147 | "neon": { 148 | "command": "wsl", 149 | "args": [ 150 | "npx", 151 | "-y", 152 | "@neondatabase/mcp-server-neon", 153 | "start", 154 | "" 155 | ] 156 | } 157 | } 158 | } 159 | ``` 160 | 161 | ## Guides 162 | 163 | - [Neon MCP Server Guide](https://neon.tech/docs/ai/neon-mcp-server) 164 | - [Connect MCP Clients to Neon](https://neon.tech/docs/ai/connect-mcp-clients-to-neon) 165 | - [Cursor with Neon MCP Server](https://neon.tech/guides/cursor-mcp-neon) 166 | - [Claude Desktop with Neon MCP Server](https://neon.tech/guides/neon-mcp-server) 167 | - [Cline with Neon MCP Server](https://neon.tech/guides/cline-mcp-neon) 168 | - [Windsurf with Neon MCP Server](https://neon.tech/guides/windsurf-mcp-neon) 169 | - [Zed with Neon MCP Server](https://neon.tech/guides/zed-mcp-neon) 170 | 171 | # Features 172 | 173 | ## Supported Tools 174 | 175 | 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. 176 | 177 | **Project Management:** 178 | 179 | - **`list_projects`**: Retrieves a list of your Neon projects, providing a summary of each project associated with your Neon account. Supports limiting the number of projects returned (default: 10). 180 | - **`describe_project`**: Fetches detailed information about a specific Neon project, including its ID, name, and associated branches and databases. 181 | - **`create_project`**: Creates a new Neon project in your Neon account. A project acts as a container for branches, databases, roles, and computes. 182 | - **`delete_project`**: Deletes an existing Neon project and all its associated resources. 183 | 184 | **Branch Management:** 185 | 186 | - **`create_branch`**: Creates a new branch within a specified Neon project. Leverages [Neon's branching](/docs/introduction/branching) feature for development, testing, or migrations. 187 | - **`delete_branch`**: Deletes an existing branch from a Neon project. 188 | - **`describe_branch`**: Retrieves details about a specific branch, such as its name, ID, and parent branch. 189 | - **`list_branch_computes`**: Lists compute endpoints for a project or specific branch, including compute ID, type, size, and autoscaling information. 190 | 191 | **SQL Query Execution:** 192 | 193 | - **`get_connection_string`**: Returns your database connection string. 194 | - **`run_sql`**: Executes a single SQL query against a specified Neon database. Supports both read and write operations. 195 | - **`run_sql_transaction`**: Executes a series of SQL queries within a single transaction against a Neon database. 196 | - **`get_database_tables`**: Lists all tables within a specified Neon database. 197 | - **`describe_table_schema`**: Retrieves the schema definition of a specific table, detailing columns, data types, and constraints. 198 | - **`list_slow_queries`**: Identifies performance bottlenecks by finding the slowest queries in a database. Requires the pg_stat_statements extension. 199 | 200 | **Database Migrations (Schema Changes):** 201 | 202 | - **`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. 203 | - **`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. 204 | 205 | **Query Performance Tuning:** 206 | 207 | - **`explain_sql_statement`**: Analyzes a SQL query and returns detailed execution plan information to help understand query performance. 208 | - **`prepare_query_tuning`**: Identifies potential performance issues in a SQL query and suggests optimizations. Creates a temporary branch for testing improvements. 209 | - **`complete_query_tuning`**: Finalizes and applies query optimizations after testing. Merges changes from the temporary tuning branch to the main branch. 210 | 211 | **Neon Auth:** 212 | 213 | - **`provision_neon_auth`**: Action to provision Neon Auth for a Neon project. It allows developers to easily setup authentication infrastructure by creating a integration with Stack Auth (`@stackframe/stack`). 214 | 215 | ## Migrations 216 | 217 | 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. 218 | 219 | 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. 220 | 221 | # Development 222 | 223 | ## Development with MCP CLI Client 224 | 225 | The easiest way to iterate on the MCP Server is using the `mcp-client/`. Learn more in `mcp-client/README.md`. 226 | 227 | ```bash 228 | npm install 229 | npm run build 230 | npm run watch # You can keep this open. 231 | cd mcp-client/ && NEON_API_KEY=... npm run start:mcp-server-neon 232 | ``` 233 | 234 | ## Development with Claude Desktop (Local MCP Server) 235 | 236 | ```bash 237 | npm install 238 | npm run build 239 | npm run watch # You can keep this open. 240 | node dist/index.js init $NEON_API_KEY 241 | ``` 242 | 243 | Then, **restart Claude** each time you want to test changes. 244 | 245 | # Testing 246 | 247 | To run the tests you need to setup the `.env` file according to the `.env.example` file. 248 | 249 | ```bash 250 | npm run test 251 | ``` 252 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import ts from 'typescript-eslint'; 3 | import prettierConfig from 'eslint-config-prettier'; 4 | 5 | // @ts-check 6 | export default ts.config( 7 | { 8 | files: ['**/*.ts', '**/*.cts', '**.*.mts'], 9 | ignores: ['**/*.js', '**/*.gen.ts'], 10 | rules: { 11 | 'no-console': 'off', 12 | '@typescript-eslint/consistent-type-definitions': ['error', 'type'], 13 | '@typescript-eslint/no-explicit-any': 'off', 14 | '@typescript-eslint/no-unsafe-member-access': 'off', 15 | '@typescript-eslint/no-unsafe-argument': 'off', 16 | '@typescript-eslint/no-unsafe-assignment': 'off', 17 | '@typescript-eslint/no-unsafe-return': 'off', 18 | '@typescript-eslint/no-unsafe-call': 'off', 19 | '@typescript-eslint/non-nullable-type-assertion-style': 'off', 20 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 21 | '@typescript-eslint/no-unnecessary-condition': 'off', 22 | '@typescript-eslint/restrict-template-expressions': [ 23 | 'error', 24 | { 25 | allowAny: true, 26 | allowBoolean: true, 27 | allowNullish: true, 28 | allowNumber: true, 29 | allowRegExp: true, 30 | }, 31 | ], 32 | }, 33 | languageOptions: { 34 | parserOptions: { 35 | project: true, 36 | tsconfigRootDir: import.meta.dirname, 37 | }, 38 | }, 39 | extends: [ 40 | js.configs.recommended, 41 | ...ts.configs.strictTypeChecked, 42 | ...ts.configs.stylisticTypeChecked, 43 | ], 44 | // see https://eslint.org/docs/latest/use/configure/configuration-files#globally-ignoring-files-with-ignores 45 | ignores: ['src/tools-evaluations/**/*'], 46 | }, 47 | prettierConfig, 48 | ); 49 | -------------------------------------------------------------------------------- /fly.preview.toml: -------------------------------------------------------------------------------- 1 | # 2 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 3 | # 4 | 5 | app = 'preview-mcp-server-neon' 6 | primary_region = 'fra' 7 | 8 | [build] 9 | dockerfile = 'Dockerfile.fly' 10 | 11 | [http_service] 12 | internal_port = 3001 13 | force_https = true 14 | auto_stop_machines = 'stop' 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ['app'] 18 | 19 | [[vm]] 20 | memory = '1gb' 21 | cpu_kind = 'shared' 22 | cpus = 1 23 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # 2 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 3 | # 4 | 5 | app = 'mcp-server-neon' 6 | primary_region = 'ams' 7 | 8 | [build] 9 | dockerfile = 'Dockerfile.fly' 10 | 11 | [http_service] 12 | internal_port = 3001 13 | force_https = true 14 | auto_stop_machines = 'stop' 15 | auto_start_machines = true 16 | min_machines_running = 1 17 | processes = ['app'] 18 | 19 | [[vm]] 20 | memory = '2gb' 21 | cpu_kind = 'performance' 22 | cpus = 1 23 | -------------------------------------------------------------------------------- /mcp-client/.env.example: -------------------------------------------------------------------------------- 1 | ANTHROPIC_API_KEY= 2 | NEON_API_KEY= 3 | -------------------------------------------------------------------------------- /mcp-client/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## MCP Client CLI 4 | 5 | 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). 6 | 7 | ## Requirements 8 | 9 | - ANTHROPIC_API_KEY - Get one from [Anthropic](https://console.anthropic.com/) 10 | - Node.js >= v18.0.0 11 | 12 | ## How to use 13 | 14 | ```bash 15 | export ANTHROPIC_API_KEY=your_key_here 16 | npx @neondatabase/mcp-client-cli --server-command="npx" --server-args="-y @neondatabase/mcp-server-neon start " 17 | ``` 18 | 19 | ## How to develop 20 | 21 | 1. Clone the repository 22 | 2. Setup a `.env` file based on the `.env.example` file 23 | 3. Run `npm install` 24 | 4. Run `npm run start:mcp-server-neon` 25 | -------------------------------------------------------------------------------- /mcp-client/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@neondatabase/mcp-client-cli", 3 | "version": "0.1.1", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@neondatabase/mcp-client-cli", 9 | "version": "0.1.1", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@anthropic-ai/sdk": "^0.32.1", 13 | "@modelcontextprotocol/sdk": "^1.0.3", 14 | "chalk": "^5.3.0", 15 | "dotenv": "16.4.7", 16 | "zod": "^3.24.1" 17 | }, 18 | "bin": { 19 | "mcp-client": "dist/bin.js" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^22.10.2", 23 | "bun": "^1.1.38", 24 | "prettier": "^3.4.1", 25 | "tsc-watch": "^6.2.1", 26 | "typescript": "^5.7.2" 27 | } 28 | }, 29 | "node_modules/@anthropic-ai/sdk": { 30 | "version": "0.32.1", 31 | "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz", 32 | "integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==", 33 | "dependencies": { 34 | "@types/node": "^18.11.18", 35 | "@types/node-fetch": "^2.6.4", 36 | "abort-controller": "^3.0.0", 37 | "agentkeepalive": "^4.2.1", 38 | "form-data-encoder": "1.7.2", 39 | "formdata-node": "^4.3.2", 40 | "node-fetch": "^2.6.7" 41 | } 42 | }, 43 | "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { 44 | "version": "18.19.68", 45 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.68.tgz", 46 | "integrity": "sha512-QGtpFH1vB99ZmTa63K4/FU8twThj4fuVSBkGddTp7uIL/cuoLWIUSL2RcOaigBhfR+hg5pgGkBnkoOxrTVBMKw==", 47 | "dependencies": { 48 | "undici-types": "~5.26.4" 49 | } 50 | }, 51 | "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { 52 | "version": "5.26.5", 53 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 54 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" 55 | }, 56 | "node_modules/@modelcontextprotocol/sdk": { 57 | "version": "1.0.3", 58 | "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.3.tgz", 59 | "integrity": "sha512-2as3cX/VJ0YBHGmdv3GFyTpoM8q2gqE98zh3Vf1NwnsSY0h3mvoO07MUzfygCKkWsFjcZm4otIiqD6Xh7kiSBQ==", 60 | "dependencies": { 61 | "content-type": "^1.0.5", 62 | "raw-body": "^3.0.0", 63 | "zod": "^3.23.8" 64 | } 65 | }, 66 | "node_modules/@oven/bun-darwin-aarch64": { 67 | "version": "1.1.38", 68 | "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.1.38.tgz", 69 | "integrity": "sha512-6r+PgOE1s56h16wHs4Tg32ZOB9JQEgLi3V+FyIag/lIKS5FV9rUjfSZSwwI8UGfNqj7RrD5cB+1PT3IFpV6gmA==", 70 | "cpu": [ 71 | "arm64" 72 | ], 73 | "dev": true, 74 | "optional": true, 75 | "os": [ 76 | "darwin" 77 | ] 78 | }, 79 | "node_modules/@oven/bun-darwin-x64": { 80 | "version": "1.1.38", 81 | "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.1.38.tgz", 82 | "integrity": "sha512-eda41VCgQcYkrvRnob1xep8zlOm0Io3q1uiBGMaNL8aSrhpYaz3NhMH1NVlZEFahfIHhCfkin/gSLhJK0qK1fg==", 83 | "cpu": [ 84 | "x64" 85 | ], 86 | "dev": true, 87 | "optional": true, 88 | "os": [ 89 | "darwin" 90 | ] 91 | }, 92 | "node_modules/@oven/bun-darwin-x64-baseline": { 93 | "version": "1.1.38", 94 | "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.1.38.tgz", 95 | "integrity": "sha512-hqaAsJGdGXiwwN6Y7dvYWjYwgAB8r3fXFIecjmxeijbOIw8zfru+zKFCBQtHa5AglAUAw1fOSOsWGlu8rtGp7Q==", 96 | "cpu": [ 97 | "x64" 98 | ], 99 | "dev": true, 100 | "optional": true, 101 | "os": [ 102 | "darwin" 103 | ] 104 | }, 105 | "node_modules/@oven/bun-linux-aarch64": { 106 | "version": "1.1.38", 107 | "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.1.38.tgz", 108 | "integrity": "sha512-YIyJ2cBEgvQAYUh1udxe6yximei2QUh6gpdGWmhHiWWRX0VhVxPpZ2E8n6NIlpM2TBy4h/hOndoImiD/XnSq5Q==", 109 | "cpu": [ 110 | "arm64" 111 | ], 112 | "dev": true, 113 | "optional": true, 114 | "os": [ 115 | "linux" 116 | ] 117 | }, 118 | "node_modules/@oven/bun-linux-x64": { 119 | "version": "1.1.38", 120 | "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.1.38.tgz", 121 | "integrity": "sha512-foVXWa2/zRPMudxVpr+/COmcF1F849g4JJHTDDzpxIp30Xp7422nSk/c0NESveklrqhCvINq4CNcKnBh3WPFAw==", 122 | "cpu": [ 123 | "x64" 124 | ], 125 | "dev": true, 126 | "optional": true, 127 | "os": [ 128 | "linux" 129 | ] 130 | }, 131 | "node_modules/@oven/bun-linux-x64-baseline": { 132 | "version": "1.1.38", 133 | "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.1.38.tgz", 134 | "integrity": "sha512-7Sv4RHpWBVjmkGjER90e99bYYkPiiNPGVP02CTBo49JwHfogVl8md8oWKr9A6K3ZZ05HS5atOg7wrKolkbR0bA==", 135 | "cpu": [ 136 | "x64" 137 | ], 138 | "dev": true, 139 | "optional": true, 140 | "os": [ 141 | "linux" 142 | ] 143 | }, 144 | "node_modules/@oven/bun-windows-x64": { 145 | "version": "1.1.38", 146 | "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.1.38.tgz", 147 | "integrity": "sha512-bMo3o7lyfC8HlyaunUXBFZVbVrYCQHHQRPXsCtgtBKzKbe/r51piwtMl4wpcvd5VZUhBDXMPrm7/OR89XXteyA==", 148 | "cpu": [ 149 | "x64" 150 | ], 151 | "dev": true, 152 | "optional": true, 153 | "os": [ 154 | "win32" 155 | ] 156 | }, 157 | "node_modules/@oven/bun-windows-x64-baseline": { 158 | "version": "1.1.38", 159 | "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.1.38.tgz", 160 | "integrity": "sha512-iwvzUC59J/aMwEsCkKyPLVc2oNep2OhWL6VRp2d9Sx0g9hycBgxOfBfAhii0bDOBI/aQAVevcTRoQJ1V79PT9Q==", 161 | "cpu": [ 162 | "x64" 163 | ], 164 | "dev": true, 165 | "optional": true, 166 | "os": [ 167 | "win32" 168 | ] 169 | }, 170 | "node_modules/@types/node": { 171 | "version": "22.10.2", 172 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", 173 | "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", 174 | "dependencies": { 175 | "undici-types": "~6.20.0" 176 | } 177 | }, 178 | "node_modules/@types/node-fetch": { 179 | "version": "2.6.12", 180 | "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", 181 | "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", 182 | "dependencies": { 183 | "@types/node": "*", 184 | "form-data": "^4.0.0" 185 | } 186 | }, 187 | "node_modules/abort-controller": { 188 | "version": "3.0.0", 189 | "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", 190 | "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", 191 | "dependencies": { 192 | "event-target-shim": "^5.0.0" 193 | }, 194 | "engines": { 195 | "node": ">=6.5" 196 | } 197 | }, 198 | "node_modules/agentkeepalive": { 199 | "version": "4.5.0", 200 | "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", 201 | "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", 202 | "dependencies": { 203 | "humanize-ms": "^1.2.1" 204 | }, 205 | "engines": { 206 | "node": ">= 8.0.0" 207 | } 208 | }, 209 | "node_modules/asynckit": { 210 | "version": "0.4.0", 211 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 212 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 213 | }, 214 | "node_modules/bun": { 215 | "version": "1.1.38", 216 | "resolved": "https://registry.npmjs.org/bun/-/bun-1.1.38.tgz", 217 | "integrity": "sha512-cr+UDFiEasyw0kKEbbD7kDewrI2vTo17JssVVjzBv/eNskeL2wikJ+4RNgUfoRqgthCjDZux7r6ELGgIGq6aWw==", 218 | "cpu": [ 219 | "arm64", 220 | "x64" 221 | ], 222 | "dev": true, 223 | "hasInstallScript": true, 224 | "os": [ 225 | "darwin", 226 | "linux", 227 | "win32" 228 | ], 229 | "bin": { 230 | "bun": "bin/bun.exe", 231 | "bunx": "bin/bun.exe" 232 | }, 233 | "optionalDependencies": { 234 | "@oven/bun-darwin-aarch64": "1.1.38", 235 | "@oven/bun-darwin-x64": "1.1.38", 236 | "@oven/bun-darwin-x64-baseline": "1.1.38", 237 | "@oven/bun-linux-aarch64": "1.1.38", 238 | "@oven/bun-linux-x64": "1.1.38", 239 | "@oven/bun-linux-x64-baseline": "1.1.38", 240 | "@oven/bun-windows-x64": "1.1.38", 241 | "@oven/bun-windows-x64-baseline": "1.1.38" 242 | } 243 | }, 244 | "node_modules/bytes": { 245 | "version": "3.1.2", 246 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 247 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", 248 | "engines": { 249 | "node": ">= 0.8" 250 | } 251 | }, 252 | "node_modules/chalk": { 253 | "version": "5.3.0", 254 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", 255 | "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", 256 | "engines": { 257 | "node": "^12.17.0 || ^14.13 || >=16.0.0" 258 | }, 259 | "funding": { 260 | "url": "https://github.com/chalk/chalk?sponsor=1" 261 | } 262 | }, 263 | "node_modules/combined-stream": { 264 | "version": "1.0.8", 265 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 266 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 267 | "dependencies": { 268 | "delayed-stream": "~1.0.0" 269 | }, 270 | "engines": { 271 | "node": ">= 0.8" 272 | } 273 | }, 274 | "node_modules/content-type": { 275 | "version": "1.0.5", 276 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 277 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", 278 | "engines": { 279 | "node": ">= 0.6" 280 | } 281 | }, 282 | "node_modules/cross-spawn": { 283 | "version": "7.0.6", 284 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 285 | "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 286 | "dev": true, 287 | "dependencies": { 288 | "path-key": "^3.1.0", 289 | "shebang-command": "^2.0.0", 290 | "which": "^2.0.1" 291 | }, 292 | "engines": { 293 | "node": ">= 8" 294 | } 295 | }, 296 | "node_modules/delayed-stream": { 297 | "version": "1.0.0", 298 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 299 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 300 | "engines": { 301 | "node": ">=0.4.0" 302 | } 303 | }, 304 | "node_modules/depd": { 305 | "version": "2.0.0", 306 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 307 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 308 | "engines": { 309 | "node": ">= 0.8" 310 | } 311 | }, 312 | "node_modules/dotenv": { 313 | "version": "16.4.7", 314 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", 315 | "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", 316 | "engines": { 317 | "node": ">=12" 318 | }, 319 | "funding": { 320 | "url": "https://dotenvx.com" 321 | } 322 | }, 323 | "node_modules/duplexer": { 324 | "version": "0.1.2", 325 | "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", 326 | "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", 327 | "dev": true 328 | }, 329 | "node_modules/event-stream": { 330 | "version": "3.3.4", 331 | "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", 332 | "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", 333 | "dev": true, 334 | "dependencies": { 335 | "duplexer": "~0.1.1", 336 | "from": "~0", 337 | "map-stream": "~0.1.0", 338 | "pause-stream": "0.0.11", 339 | "split": "0.3", 340 | "stream-combiner": "~0.0.4", 341 | "through": "~2.3.1" 342 | } 343 | }, 344 | "node_modules/event-target-shim": { 345 | "version": "5.0.1", 346 | "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", 347 | "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", 348 | "engines": { 349 | "node": ">=6" 350 | } 351 | }, 352 | "node_modules/form-data": { 353 | "version": "4.0.1", 354 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 355 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 356 | "dependencies": { 357 | "asynckit": "^0.4.0", 358 | "combined-stream": "^1.0.8", 359 | "mime-types": "^2.1.12" 360 | }, 361 | "engines": { 362 | "node": ">= 6" 363 | } 364 | }, 365 | "node_modules/form-data-encoder": { 366 | "version": "1.7.2", 367 | "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", 368 | "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" 369 | }, 370 | "node_modules/formdata-node": { 371 | "version": "4.4.1", 372 | "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", 373 | "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", 374 | "dependencies": { 375 | "node-domexception": "1.0.0", 376 | "web-streams-polyfill": "4.0.0-beta.3" 377 | }, 378 | "engines": { 379 | "node": ">= 12.20" 380 | } 381 | }, 382 | "node_modules/from": { 383 | "version": "0.1.7", 384 | "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", 385 | "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", 386 | "dev": true 387 | }, 388 | "node_modules/http-errors": { 389 | "version": "2.0.0", 390 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 391 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 392 | "dependencies": { 393 | "depd": "2.0.0", 394 | "inherits": "2.0.4", 395 | "setprototypeof": "1.2.0", 396 | "statuses": "2.0.1", 397 | "toidentifier": "1.0.1" 398 | }, 399 | "engines": { 400 | "node": ">= 0.8" 401 | } 402 | }, 403 | "node_modules/humanize-ms": { 404 | "version": "1.2.1", 405 | "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", 406 | "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", 407 | "dependencies": { 408 | "ms": "^2.0.0" 409 | } 410 | }, 411 | "node_modules/iconv-lite": { 412 | "version": "0.6.3", 413 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 414 | "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 415 | "dependencies": { 416 | "safer-buffer": ">= 2.1.2 < 3.0.0" 417 | }, 418 | "engines": { 419 | "node": ">=0.10.0" 420 | } 421 | }, 422 | "node_modules/inherits": { 423 | "version": "2.0.4", 424 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 425 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 426 | }, 427 | "node_modules/isexe": { 428 | "version": "2.0.0", 429 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 430 | "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 431 | "dev": true 432 | }, 433 | "node_modules/map-stream": { 434 | "version": "0.1.0", 435 | "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", 436 | "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", 437 | "dev": true 438 | }, 439 | "node_modules/mime-db": { 440 | "version": "1.52.0", 441 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 442 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 443 | "engines": { 444 | "node": ">= 0.6" 445 | } 446 | }, 447 | "node_modules/mime-types": { 448 | "version": "2.1.35", 449 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 450 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 451 | "dependencies": { 452 | "mime-db": "1.52.0" 453 | }, 454 | "engines": { 455 | "node": ">= 0.6" 456 | } 457 | }, 458 | "node_modules/ms": { 459 | "version": "2.1.3", 460 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 461 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 462 | }, 463 | "node_modules/node-cleanup": { 464 | "version": "2.1.2", 465 | "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", 466 | "integrity": "sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==", 467 | "dev": true 468 | }, 469 | "node_modules/node-domexception": { 470 | "version": "1.0.0", 471 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 472 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 473 | "funding": [ 474 | { 475 | "type": "github", 476 | "url": "https://github.com/sponsors/jimmywarting" 477 | }, 478 | { 479 | "type": "github", 480 | "url": "https://paypal.me/jimmywarting" 481 | } 482 | ], 483 | "engines": { 484 | "node": ">=10.5.0" 485 | } 486 | }, 487 | "node_modules/node-fetch": { 488 | "version": "2.7.0", 489 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", 490 | "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", 491 | "dependencies": { 492 | "whatwg-url": "^5.0.0" 493 | }, 494 | "engines": { 495 | "node": "4.x || >=6.0.0" 496 | }, 497 | "peerDependencies": { 498 | "encoding": "^0.1.0" 499 | }, 500 | "peerDependenciesMeta": { 501 | "encoding": { 502 | "optional": true 503 | } 504 | } 505 | }, 506 | "node_modules/path-key": { 507 | "version": "3.1.1", 508 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 509 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 510 | "dev": true, 511 | "engines": { 512 | "node": ">=8" 513 | } 514 | }, 515 | "node_modules/pause-stream": { 516 | "version": "0.0.11", 517 | "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", 518 | "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", 519 | "dev": true, 520 | "dependencies": { 521 | "through": "~2.3" 522 | } 523 | }, 524 | "node_modules/prettier": { 525 | "version": "3.4.2", 526 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", 527 | "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", 528 | "dev": true, 529 | "bin": { 530 | "prettier": "bin/prettier.cjs" 531 | }, 532 | "engines": { 533 | "node": ">=14" 534 | }, 535 | "funding": { 536 | "url": "https://github.com/prettier/prettier?sponsor=1" 537 | } 538 | }, 539 | "node_modules/ps-tree": { 540 | "version": "1.2.0", 541 | "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", 542 | "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", 543 | "dev": true, 544 | "dependencies": { 545 | "event-stream": "=3.3.4" 546 | }, 547 | "bin": { 548 | "ps-tree": "bin/ps-tree.js" 549 | }, 550 | "engines": { 551 | "node": ">= 0.10" 552 | } 553 | }, 554 | "node_modules/raw-body": { 555 | "version": "3.0.0", 556 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", 557 | "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", 558 | "dependencies": { 559 | "bytes": "3.1.2", 560 | "http-errors": "2.0.0", 561 | "iconv-lite": "0.6.3", 562 | "unpipe": "1.0.0" 563 | }, 564 | "engines": { 565 | "node": ">= 0.8" 566 | } 567 | }, 568 | "node_modules/safer-buffer": { 569 | "version": "2.1.2", 570 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 571 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 572 | }, 573 | "node_modules/setprototypeof": { 574 | "version": "1.2.0", 575 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 576 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 577 | }, 578 | "node_modules/shebang-command": { 579 | "version": "2.0.0", 580 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 581 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 582 | "dev": true, 583 | "dependencies": { 584 | "shebang-regex": "^3.0.0" 585 | }, 586 | "engines": { 587 | "node": ">=8" 588 | } 589 | }, 590 | "node_modules/shebang-regex": { 591 | "version": "3.0.0", 592 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 593 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 594 | "dev": true, 595 | "engines": { 596 | "node": ">=8" 597 | } 598 | }, 599 | "node_modules/split": { 600 | "version": "0.3.3", 601 | "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", 602 | "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", 603 | "dev": true, 604 | "dependencies": { 605 | "through": "2" 606 | }, 607 | "engines": { 608 | "node": "*" 609 | } 610 | }, 611 | "node_modules/statuses": { 612 | "version": "2.0.1", 613 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 614 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", 615 | "engines": { 616 | "node": ">= 0.8" 617 | } 618 | }, 619 | "node_modules/stream-combiner": { 620 | "version": "0.0.4", 621 | "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", 622 | "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", 623 | "dev": true, 624 | "dependencies": { 625 | "duplexer": "~0.1.1" 626 | } 627 | }, 628 | "node_modules/string-argv": { 629 | "version": "0.3.2", 630 | "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", 631 | "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", 632 | "dev": true, 633 | "engines": { 634 | "node": ">=0.6.19" 635 | } 636 | }, 637 | "node_modules/through": { 638 | "version": "2.3.8", 639 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 640 | "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", 641 | "dev": true 642 | }, 643 | "node_modules/toidentifier": { 644 | "version": "1.0.1", 645 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 646 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", 647 | "engines": { 648 | "node": ">=0.6" 649 | } 650 | }, 651 | "node_modules/tr46": { 652 | "version": "0.0.3", 653 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 654 | "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" 655 | }, 656 | "node_modules/tsc-watch": { 657 | "version": "6.2.1", 658 | "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-6.2.1.tgz", 659 | "integrity": "sha512-GLwdz5Dy9K3sVm3RzgkLcyDpl5cvU9HEcE1A3gf5rqEwlUe7gDLxNCgcuNEw3zoKOiegMo3LnbF1t6HLqxhrSA==", 660 | "dev": true, 661 | "dependencies": { 662 | "cross-spawn": "^7.0.3", 663 | "node-cleanup": "^2.1.2", 664 | "ps-tree": "^1.2.0", 665 | "string-argv": "^0.3.1" 666 | }, 667 | "bin": { 668 | "tsc-watch": "dist/lib/tsc-watch.js" 669 | }, 670 | "engines": { 671 | "node": ">=12.12.0" 672 | }, 673 | "peerDependencies": { 674 | "typescript": "*" 675 | } 676 | }, 677 | "node_modules/typescript": { 678 | "version": "5.7.2", 679 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", 680 | "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", 681 | "dev": true, 682 | "bin": { 683 | "tsc": "bin/tsc", 684 | "tsserver": "bin/tsserver" 685 | }, 686 | "engines": { 687 | "node": ">=14.17" 688 | } 689 | }, 690 | "node_modules/undici-types": { 691 | "version": "6.20.0", 692 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", 693 | "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" 694 | }, 695 | "node_modules/unpipe": { 696 | "version": "1.0.0", 697 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 698 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", 699 | "engines": { 700 | "node": ">= 0.8" 701 | } 702 | }, 703 | "node_modules/web-streams-polyfill": { 704 | "version": "4.0.0-beta.3", 705 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", 706 | "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", 707 | "engines": { 708 | "node": ">= 14" 709 | } 710 | }, 711 | "node_modules/webidl-conversions": { 712 | "version": "3.0.1", 713 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 714 | "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" 715 | }, 716 | "node_modules/whatwg-url": { 717 | "version": "5.0.0", 718 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 719 | "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 720 | "dependencies": { 721 | "tr46": "~0.0.3", 722 | "webidl-conversions": "^3.0.0" 723 | } 724 | }, 725 | "node_modules/which": { 726 | "version": "2.0.2", 727 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 728 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 729 | "dev": true, 730 | "dependencies": { 731 | "isexe": "^2.0.0" 732 | }, 733 | "bin": { 734 | "node-which": "bin/node-which" 735 | }, 736 | "engines": { 737 | "node": ">= 8" 738 | } 739 | }, 740 | "node_modules/zod": { 741 | "version": "3.24.1", 742 | "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", 743 | "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", 744 | "funding": { 745 | "url": "https://github.com/sponsors/colinhacks" 746 | } 747 | } 748 | } 749 | } 750 | -------------------------------------------------------------------------------- /mcp-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@neondatabase/mcp-client-cli", 3 | "version": "0.1.1", 4 | "description": "MCP client CLI for interacting with a MCP server", 5 | "license": "MIT", 6 | "author": "Neon, Inc. (https://neon.tech/)", 7 | "homepage": "https://github.com/neondatabase/mcp-server-neon/", 8 | "bugs": "https://github.com/neondatabase/mcp-server-neon/issues", 9 | "type": "module", 10 | "access": "public", 11 | "bin": { 12 | "mcp-client": "./dist/bin.js" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "start:mcp-server-neon": "cd .. && bun run build && cd - && bun ./src/neon-cli-client.ts", 19 | "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"", 20 | "prepare": "npm run build", 21 | "watch": "tsc-watch --onSuccess \"chmod 755 dist/index.js\"", 22 | "format": "prettier --write ." 23 | }, 24 | "dependencies": { 25 | "@anthropic-ai/sdk": "^0.32.1", 26 | "@modelcontextprotocol/sdk": "^1.0.3", 27 | "chalk": "^5.3.0", 28 | "dotenv": "16.4.7", 29 | "zod": "^3.24.1" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^22.10.2", 33 | "bun": "^1.1.38", 34 | "prettier": "^3.4.1", 35 | "tsc-watch": "^6.2.1", 36 | "typescript": "^5.7.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mcp-client/src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { parseArgs } from 'node:util'; 4 | import { MCPClientCLI } from './cli-client.js'; 5 | 6 | function checkRequiredEnvVars() { 7 | if (!process.env.ANTHROPIC_API_KEY) { 8 | console.error( 9 | '\x1b[31mError: ANTHROPIC_API_KEY environment variable is required\x1b[0m', 10 | ); 11 | console.error('Please set it before running the CLI:'); 12 | console.error(' export ANTHROPIC_API_KEY=your_key_here'); 13 | process.exit(1); 14 | } 15 | } 16 | 17 | async function main() { 18 | try { 19 | checkRequiredEnvVars(); 20 | 21 | const args = parseArgs({ 22 | options: { 23 | 'server-command': { type: 'string' }, 24 | 'server-args': { type: 'string' }, 25 | }, 26 | allowPositionals: true, 27 | }); 28 | 29 | const serverCommand = args.values['server-command']; 30 | const serverArgs = args.values['server-args']?.split(' ') || []; 31 | 32 | if (!serverCommand) { 33 | console.error('Error: --server-command is required'); 34 | process.exit(1); 35 | } 36 | 37 | const cli = new MCPClientCLI({ 38 | command: serverCommand, 39 | args: serverArgs, 40 | }); 41 | 42 | await cli.start(); 43 | } catch (error) { 44 | console.error('Failed to start CLI:', error); 45 | process.exit(1); 46 | } 47 | } 48 | 49 | main(); 50 | -------------------------------------------------------------------------------- /mcp-client/src/cli-client.ts: -------------------------------------------------------------------------------- 1 | import { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js'; 2 | import readline from 'readline/promises'; 3 | import { MCPClient } from './index.js'; 4 | import { consoleStyles, Logger } from './logger.js'; 5 | 6 | const EXIT_COMMAND = 'exit'; 7 | 8 | export class MCPClientCLI { 9 | private rl: readline.Interface; 10 | private client: MCPClient; 11 | private logger: Logger; 12 | 13 | constructor(serverConfig: StdioServerParameters) { 14 | this.client = new MCPClient(serverConfig); 15 | this.logger = new Logger({ mode: 'verbose' }); 16 | 17 | this.rl = readline.createInterface({ 18 | input: process.stdin, 19 | output: process.stdout, 20 | }); 21 | } 22 | 23 | async start() { 24 | try { 25 | this.logger.log(consoleStyles.separator + '\n', { type: 'info' }); 26 | this.logger.log('🤖 Interactive Claude CLI\n', { type: 'info' }); 27 | this.logger.log(`Type your queries or "${EXIT_COMMAND}" to exit\n`, { 28 | type: 'info', 29 | }); 30 | this.logger.log(consoleStyles.separator + '\n', { type: 'info' }); 31 | this.client.start(); 32 | 33 | await this.chat_loop(); 34 | } catch (error) { 35 | this.logger.log('Failed to initialize tools: ' + error + '\n', { 36 | type: 'error', 37 | }); 38 | process.exit(1); 39 | } finally { 40 | this.rl.close(); 41 | process.exit(0); 42 | } 43 | } 44 | 45 | private async chat_loop() { 46 | while (true) { 47 | try { 48 | const query = (await this.rl.question(consoleStyles.prompt)).trim(); 49 | if (query.toLowerCase() === EXIT_COMMAND) { 50 | this.logger.log('\nGoodbye! 👋\n', { type: 'warning' }); 51 | break; 52 | } 53 | 54 | await this.client.processQuery(query); 55 | this.logger.log('\n' + consoleStyles.separator + '\n'); 56 | } catch (error) { 57 | this.logger.log('\nError: ' + error + '\n', { type: 'error' }); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /mcp-client/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Anthropic } from '@anthropic-ai/sdk'; 2 | 3 | import { 4 | StdioClientTransport, 5 | StdioServerParameters, 6 | } from '@modelcontextprotocol/sdk/client/stdio.js'; 7 | import { 8 | ListToolsResultSchema, 9 | CallToolResultSchema, 10 | } from '@modelcontextprotocol/sdk/types.js'; 11 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 12 | import chalk from 'chalk'; 13 | import { Tool } from '@anthropic-ai/sdk/resources/index.mjs'; 14 | import { Stream } from '@anthropic-ai/sdk/streaming.mjs'; 15 | import { consoleStyles, Logger, LoggerOptions } from './logger.js'; 16 | 17 | interface Message { 18 | role: 'user' | 'assistant'; 19 | content: string; 20 | } 21 | 22 | type MCPClientOptions = StdioServerParameters & { 23 | loggerOptions?: LoggerOptions; 24 | }; 25 | 26 | export class MCPClient { 27 | private anthropicClient: Anthropic; 28 | private messages: Message[] = []; 29 | private mcpClient: Client; 30 | private transport: StdioClientTransport; 31 | private tools: Tool[] = []; 32 | private logger: Logger; 33 | 34 | constructor({ loggerOptions, ...serverConfig }: MCPClientOptions) { 35 | this.anthropicClient = new Anthropic({ 36 | apiKey: process.env.ANTHROPIC_API_KEY, 37 | }); 38 | 39 | this.mcpClient = new Client( 40 | { name: 'cli-client', version: '1.0.0' }, 41 | { capabilities: {} }, 42 | ); 43 | 44 | this.transport = new StdioClientTransport(serverConfig); 45 | this.logger = new Logger(loggerOptions ?? { mode: 'verbose' }); 46 | } 47 | 48 | async start() { 49 | try { 50 | await this.mcpClient.connect(this.transport); 51 | await this.initMCPTools(); 52 | } catch (error) { 53 | this.logger.log('Failed to initialize MCP Client: ' + error + '\n', { 54 | type: 'error', 55 | }); 56 | process.exit(1); 57 | } 58 | } 59 | 60 | async stop() { 61 | await this.mcpClient.close(); 62 | } 63 | 64 | private async initMCPTools() { 65 | const toolsResults = await this.mcpClient.request( 66 | { method: 'tools/list' }, 67 | ListToolsResultSchema, 68 | ); 69 | this.tools = toolsResults.tools.map(({ inputSchema, ...tool }) => ({ 70 | ...tool, 71 | input_schema: inputSchema, 72 | })); 73 | } 74 | 75 | private formatToolCall(toolName: string, args: any): string { 76 | return ( 77 | '\n' + 78 | consoleStyles.tool.bracket('[') + 79 | consoleStyles.tool.name(toolName) + 80 | consoleStyles.tool.bracket('] ') + 81 | consoleStyles.tool.args(JSON.stringify(args, null, 2)) + 82 | '\n' 83 | ); 84 | } 85 | 86 | private formatJSON(json: string): string { 87 | return json 88 | .replace(/"([^"]+)":/g, chalk.blue('"$1":')) 89 | .replace(/: "([^"]+)"/g, ': ' + chalk.green('"$1"')); 90 | } 91 | 92 | private async processStream( 93 | stream: Stream, 94 | ): Promise { 95 | let currentMessage = ''; 96 | let currentToolName = ''; 97 | let currentToolInputString = ''; 98 | 99 | this.logger.log(consoleStyles.assistant); 100 | for await (const chunk of stream) { 101 | switch (chunk.type) { 102 | case 'message_start': 103 | case 'content_block_stop': 104 | continue; 105 | 106 | case 'content_block_start': 107 | if (chunk.content_block?.type === 'tool_use') { 108 | currentToolName = chunk.content_block.name; 109 | } 110 | break; 111 | 112 | case 'content_block_delta': 113 | if (chunk.delta.type === 'text_delta') { 114 | this.logger.log(chunk.delta.text); 115 | currentMessage += chunk.delta.text; 116 | } else if (chunk.delta.type === 'input_json_delta') { 117 | if (currentToolName && chunk.delta.partial_json) { 118 | currentToolInputString += chunk.delta.partial_json; 119 | } 120 | } 121 | break; 122 | 123 | case 'message_delta': 124 | if (currentMessage) { 125 | this.messages.push({ 126 | role: 'assistant', 127 | content: currentMessage, 128 | }); 129 | } 130 | 131 | if (chunk.delta.stop_reason === 'tool_use') { 132 | const toolArgs = currentToolInputString 133 | ? JSON.parse(currentToolInputString) 134 | : {}; 135 | 136 | this.logger.log( 137 | this.formatToolCall(currentToolName, toolArgs) + '\n', 138 | ); 139 | const toolResult = await this.mcpClient.request( 140 | { 141 | method: 'tools/call', 142 | params: { 143 | name: currentToolName, 144 | arguments: toolArgs, 145 | }, 146 | }, 147 | CallToolResultSchema, 148 | ); 149 | 150 | const formattedResult = this.formatJSON( 151 | JSON.stringify(toolResult.content.flatMap((c) => c.text)), 152 | ); 153 | 154 | this.messages.push({ 155 | role: 'user', 156 | content: formattedResult, 157 | }); 158 | 159 | const nextStream = await this.anthropicClient.messages.create({ 160 | messages: this.messages, 161 | model: 'claude-3-5-sonnet-20241022', 162 | max_tokens: 8192, 163 | tools: this.tools, 164 | stream: true, 165 | }); 166 | await this.processStream(nextStream); 167 | } 168 | break; 169 | 170 | case 'message_stop': 171 | break; 172 | 173 | default: 174 | this.logger.log(`Unknown event type: ${JSON.stringify(chunk)}\n`, { 175 | type: 'warning', 176 | }); 177 | } 178 | } 179 | } 180 | 181 | async processQuery(query: string) { 182 | try { 183 | this.messages.push({ role: 'user', content: query }); 184 | 185 | const stream = await this.anthropicClient.messages.create({ 186 | messages: this.messages, 187 | model: 'claude-3-5-sonnet-20241022', 188 | max_tokens: 8192, 189 | tools: this.tools, 190 | stream: true, 191 | }); 192 | await this.processStream(stream); 193 | 194 | return this.messages; 195 | } catch (error) { 196 | this.logger.log('\nError during query processing: ' + error + '\n', { 197 | type: 'error', 198 | }); 199 | if (error instanceof Error) { 200 | this.logger.log( 201 | consoleStyles.assistant + 202 | 'I apologize, but I encountered an error: ' + 203 | error.message + 204 | '\n', 205 | ); 206 | } 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /mcp-client/src/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | type LoggingMode = 'verbose' | 'error' | 'none'; 4 | 5 | export type LoggerOptions = { 6 | mode: LoggingMode; 7 | }; 8 | 9 | export const consoleStyles = { 10 | prompt: chalk.green('You: '), 11 | assistant: chalk.blue('Claude: '), 12 | tool: { 13 | name: chalk.cyan.bold, 14 | args: chalk.yellow, 15 | bracket: chalk.dim, 16 | }, 17 | error: chalk.red, 18 | info: chalk.blue, 19 | success: chalk.green, 20 | warning: chalk.yellow, 21 | separator: chalk.gray('─'.repeat(50)), 22 | default: chalk, 23 | }; 24 | 25 | export class Logger { 26 | private mode: LoggingMode = 'verbose'; 27 | 28 | constructor({ mode }: LoggerOptions) { 29 | this.mode = mode; 30 | } 31 | 32 | log( 33 | message: string, 34 | options?: { type?: 'info' | 'error' | 'success' | 'warning' }, 35 | ) { 36 | if (this.mode === 'none') return; 37 | if (this.mode === 'error' && options?.type !== 'error') return; 38 | 39 | process.stdout.write(consoleStyles[options?.type ?? 'default'](message)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /mcp-client/src/neon-cli-client.ts: -------------------------------------------------------------------------------- 1 | import { MCPClientCLI } from './cli-client.js'; 2 | import path from 'path'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config({ 6 | path: path.resolve(__dirname, '../.env'), 7 | }); 8 | const cli = new MCPClientCLI({ 9 | command: path.resolve(__dirname, '../../dist/index.js'), // Use __dirname for relative path 10 | args: ['start', process.env.NEON_API_KEY!], 11 | }); 12 | 13 | cli.start(); 14 | -------------------------------------------------------------------------------- /mcp-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "outDir": "./dist" 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@neondatabase/mcp-server-neon", 3 | "version": "0.4.1", 4 | "description": "MCP server for interacting with Neon Management API and databases", 5 | "license": "MIT", 6 | "author": "Neon, Inc. (https://neon.tech/)", 7 | "homepage": "https://github.com/neondatabase/mcp-server-neon/", 8 | "bugs": "https://github.com/neondatabase/mcp-server-neon/issues", 9 | "type": "module", 10 | "access": "public", 11 | "bin": { 12 | "mcp-server-neon": "./dist/index.js" 13 | }, 14 | "files": [ 15 | "dist", 16 | "CHANGELOG.md" 17 | ], 18 | "scripts": { 19 | "typecheck": "tsc --noEmit", 20 | "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"", 21 | "watch": "tsc-watch --onSuccess \"chmod 755 dist/index.js\"", 22 | "inspector": "npx @modelcontextprotocol/inspector dist/index.js", 23 | "format": "prettier --write .", 24 | "lint": "npm run typecheck && eslint src && prettier --check .", 25 | "lint:fix": "npm run typecheck && eslint src --fix && prettier --w .", 26 | "prerelease": "npm run build", 27 | "prepublishOnly": "bun scripts/before-publish.ts", 28 | "test": "npx braintrust eval src/tools-evaluations", 29 | "start": "node dist/index.js start", 30 | "start:sse": "node dist/index.js start:sse" 31 | }, 32 | "dependencies": { 33 | "@keyv/postgres": "2.1.2", 34 | "@modelcontextprotocol/sdk": "1.11.2", 35 | "@neondatabase/api-client": "2.0.0", 36 | "@neondatabase/serverless": "1.0.0", 37 | "@segment/analytics-node": "2.2.1", 38 | "@sentry/node": "9.19.0", 39 | "body-parser": "2.2.0", 40 | "chalk": "5.3.0", 41 | "cookie-parser": "1.4.7", 42 | "cors": "2.8.5", 43 | "dotenv": "16.4.7", 44 | "express": "5.0.1", 45 | "keyv": "5.3.2", 46 | "morgan": "1.10.0", 47 | "node-fetch": "2.7.0", 48 | "oauth2-server": "3.1.1", 49 | "openid-client": "6.3.4", 50 | "pug": "3.0.3", 51 | "winston": "3.17.0", 52 | "zod": "3.24.1" 53 | }, 54 | "devDependencies": { 55 | "@eslint/js": "9.21.0", 56 | "@types/cookie-parser": "1.4.8", 57 | "@types/cors": "2.8.17", 58 | "@types/express": "5.0.1", 59 | "@types/morgan": "1.9.9", 60 | "@types/node": "20.17.9", 61 | "@types/node-fetch": "2.6.12", 62 | "@types/oauth2-server": "3.0.18", 63 | "autoevals": "0.0.111", 64 | "braintrust": "0.0.177", 65 | "bun": "1.1.40", 66 | "eslint": "9.21.0", 67 | "eslint-config-prettier": "10.0.2", 68 | "prettier": "3.4.1", 69 | "tsc-watch": "6.2.1", 70 | "typescript": "5.7.2", 71 | "typescript-eslint": "v8.25.0" 72 | }, 73 | "engines": { 74 | "node": ">=22.0.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neondatabase-labs/mcp-server-neon/bb2a9394f546836b2dbde4d225c714b3e8e758ae/public/logo.png -------------------------------------------------------------------------------- /scripts/before-publish.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { execSync } from 'child_process'; 6 | 7 | function checkMainBranch(version) { 8 | // Skip main branch check for beta versions 9 | if (version.includes('beta')) { 10 | return; 11 | } 12 | 13 | try { 14 | const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { 15 | encoding: 'utf8', 16 | }).trim(); 17 | 18 | if (currentBranch !== 'main') { 19 | console.error( 20 | '\x1b[31mError: Publishing stable versions is only allowed from the main branch\x1b[0m', 21 | ); 22 | console.error(`Current branch: ${currentBranch}`); 23 | process.exit(1); 24 | } 25 | } catch (error) { 26 | console.error('Error: Git repository not found'); 27 | process.exit(1); 28 | } 29 | } 30 | 31 | function checkChangelog() { 32 | const changelogPath = path.join(__dirname, '../CHANGELOG.md'); 33 | const packagePath = path.join(__dirname, '../package.json'); 34 | 35 | const { version } = require(packagePath); 36 | 37 | try { 38 | const changelog = fs.readFileSync(changelogPath, 'utf8'); 39 | if (!changelog.includes(version)) { 40 | console.error( 41 | `\x1b[31mError: Version ${version} not found in CHANGELOG.md\x1b[0m`, 42 | ); 43 | console.error('Please update the changelog before publishing'); 44 | process.exit(1); 45 | } 46 | return version; 47 | } catch (err) { 48 | console.error('\x1b[31mError: CHANGELOG.md not found\x1b[0m'); 49 | process.exit(1); 50 | } 51 | } 52 | 53 | function beforePublish() { 54 | const version = checkChangelog(); 55 | checkMainBranch(version); 56 | } 57 | 58 | beforePublish(); 59 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - neonApiKey 10 | properties: 11 | neonApiKey: 12 | type: string 13 | description: The API key for accessing the Neon. You can generate one through the Neon console. 14 | commandFunction: 15 | # A function that produces the CLI command to start the MCP on stdio. 16 | |- 17 | (config) => ({command: 'node', args: ['dist/index.js', 'start', config.neonApiKey]}) 18 | -------------------------------------------------------------------------------- /src/analytics/analytics.ts: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@segment/analytics-node'; 2 | import { ANALYTICS_WRITE_KEY } from '../constants.js'; 3 | import { CurrentUserInfoResponse } from '@neondatabase/api-client'; 4 | 5 | let analytics: Analytics | undefined; 6 | type User = Pick; 7 | export const initAnalytics = () => { 8 | if (ANALYTICS_WRITE_KEY) { 9 | analytics = new Analytics({ 10 | writeKey: ANALYTICS_WRITE_KEY, 11 | host: 'https://track.neon.tech', 12 | }); 13 | } 14 | }; 15 | 16 | export const identify = ( 17 | user: User | null, 18 | params: Omit[0], 'userId' | 'anonymousId'>, 19 | ) => { 20 | if (user) { 21 | analytics?.identify({ 22 | ...params, 23 | userId: user.id, 24 | traits: { 25 | name: user.name, 26 | email: user.email, 27 | }, 28 | }); 29 | } else { 30 | analytics?.identify({ 31 | ...params, 32 | anonymousId: 'anonymous', 33 | }); 34 | } 35 | }; 36 | 37 | export const track = (params: Parameters[0]) => { 38 | analytics?.track(params); 39 | }; 40 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | 3 | config(); 4 | 5 | export type Environment = 'development' | 'production' | 'preview'; 6 | export const NEON_DEFAULT_DATABASE_NAME = 'neondb'; 7 | 8 | export const NODE_ENV = (process.env.NODE_ENV ?? 'production') as Environment; 9 | export const IS_DEV = NODE_ENV === 'development'; 10 | export const SERVER_PORT = 3001; 11 | export const SERVER_HOST = 12 | process.env.SERVER_HOST ?? `http://localhost:${SERVER_PORT}`; 13 | export const CLIENT_ID = process.env.CLIENT_ID ?? ''; 14 | export const CLIENT_SECRET = process.env.CLIENT_SECRET ?? ''; 15 | export const UPSTREAM_OAUTH_HOST = 16 | process.env.UPSTREAM_OAUTH_HOST ?? 'https://oauth2.neon.tech'; 17 | export const REDIRECT_URI = `${SERVER_HOST}/callback`; 18 | export const NEON_API_HOST = 19 | process.env.NEON_API_HOST ?? 'https://console.neon.tech/api/v2'; 20 | export const COOKIE_SECRET = process.env.COOKIE_SECRET ?? ''; 21 | export const ANALYTICS_WRITE_KEY = 22 | process.env.ANALYTICS_WRITE_KEY ?? 'gFVzt8ozOp6AZRXoD0g0Lv6UQ6aaoS7O'; 23 | export const SENTRY_DSN = 24 | process.env.SENTRY_DSN ?? 25 | 'https://b3564134667aa2dfeaa3992a12d9c12f@o1373725.ingest.us.sentry.io/4509328350380033'; 26 | -------------------------------------------------------------------------------- /src/describeUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is derived from @neondatabase/psql-describe 3 | * Original source: https://github.com/neondatabase/psql-describe 4 | */ 5 | 6 | import { neon } from '@neondatabase/serverless'; 7 | 8 | export type TableDescription = { 9 | columns: ColumnDescription[]; 10 | indexes: IndexDescription[]; 11 | constraints: ConstraintDescription[]; 12 | tableSize: string; 13 | indexSize: string; 14 | totalSize: string; 15 | }; 16 | 17 | export type ColumnDescription = { 18 | name: string; 19 | type: string; 20 | nullable: boolean; 21 | default: string | null; 22 | description: string | null; 23 | }; 24 | 25 | export type IndexDescription = { 26 | name: string; 27 | definition: string; 28 | size: string; 29 | }; 30 | 31 | export type ConstraintDescription = { 32 | name: string; 33 | type: string; 34 | definition: string; 35 | }; 36 | 37 | export const DESCRIBE_TABLE_STATEMENTS = [ 38 | // Get column information 39 | ` 40 | SELECT 41 | c.column_name as name, 42 | c.data_type as type, 43 | c.is_nullable = 'YES' as nullable, 44 | c.column_default as default, 45 | pd.description 46 | FROM information_schema.columns c 47 | LEFT JOIN pg_catalog.pg_statio_all_tables st ON c.table_schema = st.schemaname AND c.table_name = st.relname 48 | LEFT JOIN pg_catalog.pg_description pd ON pd.objoid = st.relid AND pd.objsubid = c.ordinal_position 49 | WHERE c.table_schema = 'public' AND c.table_name = $1 50 | ORDER BY c.ordinal_position; 51 | `, 52 | 53 | // Get index information 54 | ` 55 | SELECT 56 | i.relname as name, 57 | pg_get_indexdef(i.oid) as definition, 58 | pg_size_pretty(pg_relation_size(i.oid)) as size 59 | FROM pg_class t 60 | JOIN pg_index ix ON t.oid = ix.indrelid 61 | JOIN pg_class i ON i.oid = ix.indexrelid 62 | WHERE t.relname = $1 AND t.relkind = 'r'; 63 | `, 64 | 65 | // Get constraint information 66 | ` 67 | SELECT 68 | tc.constraint_name as name, 69 | tc.constraint_type as type, 70 | pg_get_constraintdef(cc.oid) as definition 71 | FROM information_schema.table_constraints tc 72 | JOIN pg_catalog.pg_constraint cc ON tc.constraint_name = cc.conname 73 | WHERE tc.table_schema = 'public' AND tc.table_name = $1; 74 | `, 75 | 76 | // Get table size information 77 | ` 78 | SELECT 79 | pg_size_pretty(pg_total_relation_size($1)) as total_size, 80 | pg_size_pretty(pg_relation_size($1)) as table_size, 81 | pg_size_pretty(pg_total_relation_size($1) - pg_relation_size($1)) as index_size; 82 | `, 83 | ]; 84 | 85 | export async function describeTable( 86 | connectionString: string, 87 | tableName: string, 88 | ): Promise { 89 | const sql = neon(connectionString); 90 | 91 | // Execute all queries in parallel 92 | const [columns, indexes, constraints, sizes] = await Promise.all([ 93 | sql.query(DESCRIBE_TABLE_STATEMENTS[0], [tableName]), 94 | sql.query(DESCRIBE_TABLE_STATEMENTS[1], [tableName]), 95 | sql.query(DESCRIBE_TABLE_STATEMENTS[2], [tableName]), 96 | sql.query(DESCRIBE_TABLE_STATEMENTS[3], [tableName]), 97 | ]); 98 | 99 | return { 100 | columns: columns.map((col) => ({ 101 | name: col.name, 102 | type: col.type, 103 | nullable: col.nullable, 104 | default: col.default, 105 | description: col.description, 106 | })), 107 | indexes: indexes.map((idx) => ({ 108 | name: idx.name, 109 | definition: idx.definition, 110 | size: idx.size, 111 | })), 112 | constraints: constraints.map((con) => ({ 113 | name: con.name, 114 | type: con.type, 115 | definition: con.definition, 116 | })), 117 | tableSize: sizes[0].table_size, 118 | indexSize: sizes[0].index_size, 119 | totalSize: sizes[0].total_size, 120 | }; 121 | } 122 | 123 | export function formatTableDescription(desc: TableDescription): string { 124 | const lines: string[] = []; 125 | 126 | // Add table size information 127 | lines.push(`Table size: ${desc.tableSize}`); 128 | lines.push(`Index size: ${desc.indexSize}`); 129 | lines.push(`Total size: ${desc.totalSize}`); 130 | lines.push(''); 131 | 132 | // Add columns 133 | lines.push('Columns:'); 134 | desc.columns.forEach((col) => { 135 | const nullable = col.nullable ? 'NULL' : 'NOT NULL'; 136 | const defaultStr = col.default ? ` DEFAULT ${col.default}` : ''; 137 | const descStr = col.description ? `\n ${col.description}` : ''; 138 | lines.push(` ${col.name} ${col.type} ${nullable}${defaultStr}${descStr}`); 139 | }); 140 | lines.push(''); 141 | 142 | // Add indexes 143 | if (desc.indexes.length > 0) { 144 | lines.push('Indexes:'); 145 | desc.indexes.forEach((idx) => { 146 | lines.push(` ${idx.name} (${idx.size})`); 147 | lines.push(` ${idx.definition}`); 148 | }); 149 | lines.push(''); 150 | } 151 | 152 | // Add constraints 153 | if (desc.constraints.length > 0) { 154 | lines.push('Constraints:'); 155 | desc.constraints.forEach((con) => { 156 | lines.push(` ${con.name} (${con.type})`); 157 | lines.push(` ${con.definition}`); 158 | }); 159 | } 160 | 161 | return lines.join('\n'); 162 | } 163 | -------------------------------------------------------------------------------- /src/handlers/neon-auth.ts: -------------------------------------------------------------------------------- 1 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 2 | 3 | import { Api, NeonAuthSupportedAuthProvider } from '@neondatabase/api-client'; 4 | import { provisionNeonAuthInputSchema } from '../toolsSchema.js'; 5 | import { z } from 'zod'; 6 | import { getDefaultDatabase } from '../utils.js'; 7 | 8 | type Props = z.infer; 9 | export async function handleProvisionNeonAuth( 10 | { projectId, database }: Props, 11 | neonClient: Api, 12 | ): Promise { 13 | const { 14 | data: { branches }, 15 | } = await neonClient.listProjectBranches({ 16 | projectId, 17 | }); 18 | const defaultBranch = 19 | branches.find((branch) => branch.default) ?? branches[0]; 20 | if (!defaultBranch) { 21 | return { 22 | isError: true, 23 | content: [ 24 | { 25 | type: 'text', 26 | text: 'The project has no default branch. Neon Auth can only be provisioned with a default branch.', 27 | }, 28 | ], 29 | }; 30 | } 31 | const defaultDatabase = await getDefaultDatabase( 32 | { 33 | projectId, 34 | branchId: defaultBranch.id, 35 | databaseName: database, 36 | }, 37 | neonClient, 38 | ); 39 | 40 | if (!defaultDatabase) { 41 | return { 42 | isError: true, 43 | content: [ 44 | { 45 | type: 'text', 46 | text: `The project has no database named '${database}'.`, 47 | }, 48 | ], 49 | }; 50 | } 51 | 52 | const response = await neonClient.createNeonAuthIntegration({ 53 | auth_provider: NeonAuthSupportedAuthProvider.Stack, 54 | project_id: projectId, 55 | branch_id: defaultBranch.id, 56 | database_name: defaultDatabase.name, 57 | role_name: defaultDatabase.owner_name, 58 | }); 59 | 60 | // In case of 409, it means that the integration already exists 61 | // We should not return an error, but a message that the integration already exists and fetch the existing integration 62 | if (response.status === 409) { 63 | return { 64 | content: [ 65 | { 66 | type: 'text', 67 | text: 'Neon Auth already provisioned.', 68 | }, 69 | ], 70 | }; 71 | } 72 | 73 | if (response.status !== 201) { 74 | return { 75 | isError: true, 76 | content: [ 77 | { 78 | type: 'text', 79 | text: `Failed to provision Neon Auth. Error: ${response.statusText}`, 80 | }, 81 | ], 82 | }; 83 | } 84 | 85 | return { 86 | content: [ 87 | { 88 | type: 'text', 89 | text: `Authentication has been successfully provisioned for your Neon project. Following are the environment variables you need to set in your project: 90 | \`\`\` 91 | NEXT_PUBLIC_STACK_PROJECT_ID='${response.data.auth_provider_project_id}' 92 | NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY='${response.data.pub_client_key}' 93 | STACK_SECRET_SERVER_KEY='${response.data.secret_server_key}' 94 | \`\`\` 95 | 96 | Copy the above environment variables and place them in your \`.env.local\` file for Next.js project. Note that variables with \`NEXT_PUBLIC_\` prefix will be available in the client side. 97 | `, 98 | }, 99 | { 100 | type: 'text', 101 | text: ` 102 | Use Following JWKS URL to retrieve the public key to verify the JSON Web Tokens (JWT) issued by authentication provider: 103 | \`\`\` 104 | ${response.data.jwks_url} 105 | \`\`\` 106 | `, 107 | }, 108 | ], 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { identify, initAnalytics, track } from './analytics/analytics.js'; 4 | import { NODE_ENV } from './constants.js'; 5 | import { handleInit, parseArgs } from './initConfig.js'; 6 | import { createNeonClient, getPackageJson } from './server/api.js'; 7 | import { createMcpServer } from './server/index.js'; 8 | import { createSseTransport } from './transports/sse-express.js'; 9 | import { startStdio } from './transports/stdio.js'; 10 | import { logger } from './utils/logger.js'; 11 | import { AppContext } from './types/context.js'; 12 | import './utils/polyfills.js'; 13 | 14 | const args = parseArgs(); 15 | const appVersion = getPackageJson().version; 16 | const appName = getPackageJson().name; 17 | 18 | const appContext: AppContext = { 19 | environment: NODE_ENV, 20 | name: appName, 21 | version: appVersion, 22 | transport: 'stdio', 23 | }; 24 | 25 | if (args.analytics) { 26 | initAnalytics(); 27 | } 28 | 29 | if (args.command === 'start:sse') { 30 | createSseTransport({ 31 | ...appContext, 32 | transport: 'sse', 33 | }); 34 | } else { 35 | // Turn off logger in stdio mode to avoid capturing stderr in wrong format by host application (Claude Desktop) 36 | logger.silent = true; 37 | 38 | try { 39 | const neonClient = createNeonClient(args.neonApiKey); 40 | const { data: user } = await neonClient.getCurrentUserInfo(); 41 | identify(user, { 42 | context: appContext, 43 | }); 44 | 45 | if (args.command === 'init') { 46 | track({ 47 | userId: user.id, 48 | event: 'init_stdio', 49 | context: appContext, 50 | }); 51 | handleInit({ 52 | executablePath: args.executablePath, 53 | neonApiKey: args.neonApiKey, 54 | analytics: args.analytics, 55 | }); 56 | process.exit(0); 57 | } 58 | 59 | if (args.command === 'start') { 60 | track({ 61 | userId: user.id, 62 | event: 'start_stdio', 63 | context: appContext, 64 | }); 65 | const server = createMcpServer({ 66 | apiKey: args.neonApiKey, 67 | user: { 68 | id: user.id, 69 | name: user.name, 70 | email: user.email, 71 | }, 72 | app: appContext, 73 | }); 74 | await startStdio(server); 75 | } 76 | } catch (error) { 77 | console.error('Server error:', error); 78 | track({ 79 | anonymousId: 'anonymous', 80 | event: 'server_error', 81 | properties: { error }, 82 | context: appContext, 83 | }); 84 | process.exit(1); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/initConfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import os from 'node:os'; 3 | import fs from 'node:fs'; 4 | import chalk from 'chalk'; 5 | import { fileURLToPath } from 'url'; 6 | import { logger } from './utils/logger.js'; 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | const packageJson = JSON.parse( 10 | fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'), 11 | ); 12 | // Determine Claude config path based on OS platform 13 | let claudeConfigPath: string; 14 | const platform = os.platform(); 15 | 16 | if (platform === 'win32') { 17 | // Windows path - using %APPDATA% 18 | // For Node.js, we access %APPDATA% via process.env.APPDATA 19 | claudeConfigPath = path.join( 20 | process.env.APPDATA || '', 21 | 'Claude', 22 | 'claude_desktop_config.json', 23 | ); 24 | } else { 25 | // macOS and Linux path (according to official docs) 26 | claudeConfigPath = path.join( 27 | os.homedir(), 28 | 'Library', 29 | 'Application Support', 30 | 'Claude', 31 | 'claude_desktop_config.json', 32 | ); 33 | } 34 | 35 | const MCP_NEON_SERVER = 'neon'; 36 | 37 | type Args = 38 | | { 39 | command: 'start:sse'; 40 | analytics: boolean; 41 | } 42 | | { 43 | command: 'start'; 44 | neonApiKey: string; 45 | analytics: boolean; 46 | } 47 | | { 48 | command: 'init'; 49 | executablePath: string; 50 | neonApiKey: string; 51 | analytics: boolean; 52 | }; 53 | 54 | const commands = ['init', 'start', 'start:sse'] as const; 55 | export const parseArgs = (): Args => { 56 | const args = process.argv; 57 | 58 | if (args.length < 3) { 59 | logger.error('Invalid number of arguments'); 60 | process.exit(1); 61 | } 62 | 63 | if (args.length === 3 && args[2] === 'start:sse') { 64 | return { 65 | command: 'start:sse', 66 | analytics: true, 67 | }; 68 | } 69 | 70 | const command = args[2]; 71 | if (!commands.includes(command as (typeof commands)[number])) { 72 | logger.error(`Invalid command: ${command}`); 73 | process.exit(1); 74 | } 75 | 76 | if (args.length < 3) { 77 | logger.error( 78 | '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', 79 | ); 80 | process.exit(1); 81 | } 82 | 83 | return { 84 | executablePath: args[1], 85 | command: args[2] as 'start' | 'init', 86 | neonApiKey: args[3], 87 | analytics: !args[4]?.includes('no-analytics'), 88 | }; 89 | }; 90 | 91 | export function handleInit({ 92 | executablePath, 93 | neonApiKey, 94 | analytics, 95 | }: { 96 | executablePath: string; 97 | neonApiKey: string; 98 | analytics: boolean; 99 | }) { 100 | // If the executable path is a local path to the dist/index.js file, use it directly 101 | // Otherwise, use the name of the package to always load the latest version from remote 102 | const serverPath = executablePath.includes('dist/index.js') 103 | ? executablePath 104 | : packageJson.name; 105 | 106 | const neonConfig = { 107 | command: 'npx', 108 | args: [ 109 | '-y', 110 | serverPath, 111 | 'start', 112 | neonApiKey, 113 | analytics ? '' : '--no-analytics', 114 | ], 115 | }; 116 | 117 | const configDir = path.dirname(claudeConfigPath); 118 | if (!fs.existsSync(configDir)) { 119 | console.log(chalk.blue('Creating Claude config directory...')); 120 | fs.mkdirSync(configDir, { recursive: true }); 121 | } 122 | 123 | const existingConfig = fs.existsSync(claudeConfigPath) 124 | ? JSON.parse(fs.readFileSync(claudeConfigPath, 'utf8')) 125 | : { mcpServers: {} }; 126 | 127 | if (MCP_NEON_SERVER in (existingConfig?.mcpServers || {})) { 128 | console.log(chalk.yellow('Replacing existing Neon MCP config...')); 129 | } 130 | 131 | const newConfig = { 132 | ...existingConfig, 133 | mcpServers: { 134 | ...existingConfig.mcpServers, 135 | [MCP_NEON_SERVER]: neonConfig, 136 | }, 137 | }; 138 | 139 | fs.writeFileSync(claudeConfigPath, JSON.stringify(newConfig, null, 2)); 140 | console.log(chalk.green(`Config written to: ${claudeConfigPath}`)); 141 | console.log( 142 | chalk.blue( 143 | 'The Neon MCP server will start automatically the next time you open Claude.', 144 | ), 145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /src/oauth/client.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { 3 | discovery, 4 | buildAuthorizationUrl, 5 | authorizationCodeGrant, 6 | ClientSecretPost, 7 | refreshTokenGrant, 8 | } from 'openid-client'; 9 | import { 10 | CLIENT_ID, 11 | CLIENT_SECRET, 12 | UPSTREAM_OAUTH_HOST, 13 | REDIRECT_URI, 14 | SERVER_HOST, 15 | } from '../constants.js'; 16 | import { logger } from '../utils/logger.js'; 17 | 18 | const NEON_MCP_SCOPES = [ 19 | 'openid', 20 | 'offline', 21 | 'offline_access', 22 | 'urn:neoncloud:projects:create', 23 | 'urn:neoncloud:projects:read', 24 | 'urn:neoncloud:projects:update', 25 | 'urn:neoncloud:projects:delete', 26 | 'urn:neoncloud:orgs:create', 27 | 'urn:neoncloud:orgs:read', 28 | 'urn:neoncloud:orgs:update', 29 | 'urn:neoncloud:orgs:delete', 30 | 'urn:neoncloud:orgs:permission', 31 | ] as const; 32 | 33 | const getUpstreamConfig = async () => { 34 | const url = new URL(UPSTREAM_OAUTH_HOST); 35 | const config = await discovery( 36 | url, 37 | CLIENT_ID, 38 | { 39 | client_secret: CLIENT_SECRET, 40 | }, 41 | ClientSecretPost(CLIENT_SECRET), 42 | {}, 43 | ); 44 | 45 | return config; 46 | }; 47 | 48 | export const upstreamAuth = async (state: string) => { 49 | const config = await getUpstreamConfig(); 50 | return buildAuthorizationUrl(config, { 51 | redirect_uri: REDIRECT_URI, 52 | token_endpoint_auth_method: 'client_secret_post', 53 | scope: NEON_MCP_SCOPES.join(' '), 54 | response_type: 'code', 55 | state, 56 | }); 57 | }; 58 | 59 | export const exchangeCode = async (req: Request) => { 60 | try { 61 | const config = await getUpstreamConfig(); 62 | const currentUrl = new URL(req.originalUrl, SERVER_HOST); 63 | return await authorizationCodeGrant(config, currentUrl, { 64 | expectedState: req.query.state as string, 65 | idTokenExpected: true, 66 | }); 67 | } catch (error: unknown) { 68 | logger.error('failed to exchange code:', { 69 | message: error instanceof Error ? error.message : 'Unknown error', 70 | error, 71 | }); 72 | throw error; 73 | } 74 | }; 75 | 76 | export const exchangeRefreshToken = async (token: string) => { 77 | const config = await getUpstreamConfig(); 78 | return refreshTokenGrant(config, token); 79 | }; 80 | -------------------------------------------------------------------------------- /src/oauth/cookies.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Request as ExpressRequest, 3 | Response as ExpressResponse, 4 | } from 'express'; 5 | 6 | const COOKIE_NAME = 'approved-mcp-clients'; 7 | const ONE_YEAR_IN_SECONDS = 365 * 24 * 60 * 60 * 1000; // 365 days 8 | 9 | /** 10 | * Imports a secret key string for HMAC-SHA256 signing. 11 | * @param secret - The raw secret key string. 12 | * @returns A promise resolving to the CryptoKey object. 13 | */ 14 | const importKey = async (secret: string): Promise => { 15 | const enc = new TextEncoder(); 16 | return crypto.subtle.importKey( 17 | 'raw', 18 | enc.encode(secret), 19 | { name: 'HMAC', hash: 'SHA-256' }, 20 | false, 21 | ['sign', 'verify'], 22 | ); 23 | }; 24 | 25 | /** 26 | * Signs data using HMAC-SHA256. 27 | * @param key - The CryptoKey for signing. 28 | * @param data - The string data to sign. 29 | * @returns A promise resolving to the signature as a hex string. 30 | */ 31 | const signData = async (key: CryptoKey, data: string): Promise => { 32 | const enc = new TextEncoder(); 33 | const signatureBuffer = await crypto.subtle.sign( 34 | 'HMAC', 35 | key, 36 | enc.encode(data), 37 | ); 38 | // Convert ArrayBuffer to hex string 39 | return Array.from(new Uint8Array(signatureBuffer)) 40 | .map((b) => b.toString(16).padStart(2, '0')) 41 | .join(''); 42 | }; 43 | 44 | /** 45 | * Verifies an HMAC-SHA256 signature. 46 | * @param key - The CryptoKey for verification. 47 | * @param signatureHex - The signature to verify (hex string). 48 | * @param data - The original data that was signed. 49 | * @returns A promise resolving to true if the signature is valid, false otherwise. 50 | */ 51 | const verifySignature = async ( 52 | key: CryptoKey, 53 | signatureHex: string, 54 | data: string, 55 | ): Promise => { 56 | try { 57 | // Convert hex signature back to ArrayBuffer 58 | const enc = new TextEncoder(); 59 | const signatureBytes = new Uint8Array( 60 | signatureHex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? [], 61 | ); 62 | 63 | return await crypto.subtle.verify( 64 | 'HMAC', 65 | key, 66 | signatureBytes.buffer, 67 | enc.encode(data), 68 | ); 69 | } catch (e) { 70 | // Handle errors during hex parsing or verification 71 | console.error('Error verifying signature:', e); 72 | return false; 73 | } 74 | }; 75 | 76 | /** 77 | * Parses the signed cookie and verifies its integrity. 78 | * @param cookieHeader - The value of the Cookie header from the request. 79 | * @param secret - The secret key used for signing. 80 | * @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null. 81 | */ 82 | const getApprovedClientsFromCookie = async ( 83 | cookie: string, 84 | secret: string, 85 | ): Promise => { 86 | if (!cookie) return []; 87 | 88 | try { 89 | const [signatureHex, base64Payload] = cookie.split('.'); 90 | if (!signatureHex || !base64Payload) return []; 91 | 92 | const payload = atob(base64Payload); 93 | const key = await importKey(secret); 94 | const isValid = await verifySignature(key, signatureHex, payload); 95 | if (!isValid) return []; 96 | 97 | const clients = JSON.parse(payload); 98 | return Array.isArray(clients) ? clients : []; 99 | } catch { 100 | return []; 101 | } 102 | }; 103 | 104 | /** 105 | * Checks if a given client has already been approved by the user, 106 | * based on a signed cookie. 107 | * 108 | * @param request - The incoming Request object to read cookies from. 109 | * @param clientId - The OAuth client ID to check approval for. 110 | * @param cookieSecret - The secret key used to sign/verify the approval cookie. 111 | * @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise. 112 | */ 113 | export const isClientAlreadyApproved = async ( 114 | req: ExpressRequest, 115 | clientId: string, 116 | cookieSecret: string, 117 | ) => { 118 | const approvedClients = await getApprovedClientsFromCookie( 119 | req.cookies[COOKIE_NAME] ?? '', 120 | cookieSecret, 121 | ); 122 | return approvedClients.includes(clientId); 123 | }; 124 | 125 | /** 126 | * Updates the approved clients cookie with a new client ID. 127 | * The cookie is signed using HMAC-SHA256 for integrity. 128 | * 129 | * @param request - Express request containing existing cookie 130 | * @param clientId - Client ID to add to approved list 131 | * @param cookieSecret - Secret key for signing cookie 132 | * @returns Cookie string with updated approved clients list 133 | */ 134 | export const updateApprovedClientsCookie = async ( 135 | req: ExpressRequest, 136 | res: ExpressResponse, 137 | clientId: string, 138 | cookieSecret: string, 139 | ) => { 140 | const approvedClients = await getApprovedClientsFromCookie( 141 | req.cookies[COOKIE_NAME] ?? '', 142 | cookieSecret, 143 | ); 144 | const newApprovedClients = JSON.stringify( 145 | Array.from(new Set([...approvedClients, clientId])), 146 | ); 147 | const key = await importKey(cookieSecret); 148 | const signature = await signData(key, newApprovedClients); 149 | res.cookie(COOKIE_NAME, `${signature}.${btoa(newApprovedClients)}`, { 150 | httpOnly: true, 151 | secure: true, 152 | sameSite: 'lax', 153 | maxAge: ONE_YEAR_IN_SECONDS, 154 | path: '/', 155 | }); 156 | }; 157 | -------------------------------------------------------------------------------- /src/oauth/kv-store.ts: -------------------------------------------------------------------------------- 1 | import { KeyvPostgres, KeyvPostgresOptions } from '@keyv/postgres'; 2 | import { logger } from '../utils/logger.js'; 3 | import { AuthorizationCode, Client, Token } from 'oauth2-server'; 4 | import Keyv from 'keyv'; 5 | 6 | const SCHEMA = 'mcpauth'; 7 | 8 | const createKeyv = (options: KeyvPostgresOptions) => 9 | new Keyv({ store: new KeyvPostgres(options) }); 10 | 11 | export const clients = createKeyv({ 12 | connectionString: process.env.OAUTH_DATABASE_URL, 13 | schema: SCHEMA, 14 | table: 'clients', 15 | }); 16 | 17 | clients.on('error', (err) => { 18 | logger.error('Clients keyv error:', { err }); 19 | }); 20 | 21 | export const tokens = createKeyv({ 22 | connectionString: process.env.OAUTH_DATABASE_URL, 23 | schema: SCHEMA, 24 | table: 'tokens', 25 | }); 26 | 27 | tokens.on('error', (err) => { 28 | logger.error('Tokens keyv error:', { err }); 29 | }); 30 | 31 | export type RefreshToken = { 32 | refreshToken: string; 33 | refreshTokenExpiresAt?: Date | undefined; 34 | accessToken: string; 35 | }; 36 | 37 | export const refreshTokens = createKeyv({ 38 | connectionString: process.env.OAUTH_DATABASE_URL, 39 | schema: SCHEMA, 40 | table: 'refresh_tokens', 41 | }); 42 | 43 | refreshTokens.on('error', (err) => { 44 | logger.error('Refresh tokens keyv error:', { err }); 45 | }); 46 | 47 | export const authorizationCodes = createKeyv({ 48 | connectionString: process.env.OAUTH_DATABASE_URL, 49 | schema: SCHEMA, 50 | table: 'authorization_codes', 51 | }); 52 | 53 | authorizationCodes.on('error', (err) => { 54 | logger.error('Authorization codes keyv error:', { err }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/oauth/model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthorizationCode, 3 | AuthorizationCodeModel, 4 | Client, 5 | Token, 6 | User, 7 | } from 'oauth2-server'; 8 | import { 9 | clients, 10 | tokens, 11 | refreshTokens, 12 | authorizationCodes, 13 | RefreshToken, 14 | } from './kv-store.js'; 15 | 16 | class Model implements AuthorizationCodeModel { 17 | getClient: ( 18 | clientId: string, 19 | clientSecret: string, 20 | ) => Promise = async (clientId) => { 21 | return clients.get(clientId); 22 | }; 23 | saveClient: (client: Client) => Promise = async (client) => { 24 | await clients.set(client.id, client); 25 | return client; 26 | }; 27 | saveToken: (token: Token) => Promise = async (token) => { 28 | await tokens.set(token.accessToken, token); 29 | return token; 30 | }; 31 | deleteToken: (token: Token) => Promise = async (token) => { 32 | return tokens.delete(token.accessToken); 33 | }; 34 | saveRefreshToken: (token: RefreshToken) => Promise = async ( 35 | token, 36 | ) => { 37 | await refreshTokens.set(token.refreshToken, token); 38 | return token; 39 | }; 40 | deleteRefreshToken: (token: RefreshToken) => Promise = async ( 41 | token, 42 | ) => { 43 | return refreshTokens.delete(token.refreshToken); 44 | }; 45 | 46 | validateScope: ( 47 | user: User, 48 | client: Client, 49 | scope: string, 50 | ) => Promise = (user, client, scope) => { 51 | // For demo purposes, accept all scopes 52 | return Promise.resolve(scope); 53 | }; 54 | verifyScope: (token: Token, scope: string) => Promise = () => { 55 | // For demo purposes, accept all scopes 56 | return Promise.resolve(true); 57 | }; 58 | getAccessToken: (accessToken: string) => Promise = async ( 59 | accessToken, 60 | ) => { 61 | const token = await tokens.get(accessToken); 62 | return token; 63 | }; 64 | getRefreshToken: (refreshToken: string) => Promise = 65 | async (refreshToken) => { 66 | return refreshTokens.get(refreshToken); 67 | }; 68 | saveAuthorizationCode: ( 69 | code: AuthorizationCode, 70 | ) => Promise = async (code) => { 71 | await authorizationCodes.set(code.authorizationCode, code); 72 | return code; 73 | }; 74 | getAuthorizationCode: ( 75 | code: string, 76 | ) => Promise = async (code) => { 77 | return authorizationCodes.get(code); 78 | }; 79 | revokeAuthorizationCode: (code: AuthorizationCode) => Promise = 80 | async (code) => { 81 | return authorizationCodes.delete(code.authorizationCode); 82 | }; 83 | } 84 | 85 | export const model = new Model(); 86 | -------------------------------------------------------------------------------- /src/oauth/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Request as ExpressRequest, 3 | Response as ExpressResponse, 4 | } from 'express'; 5 | import { AuthorizationCode, Client } from 'oauth2-server'; 6 | import { model } from './model.js'; 7 | import { logger } from '../utils/logger.js'; 8 | import express from 'express'; 9 | import { 10 | decodeAuthParams, 11 | extractClientCredentials, 12 | generateRandomString, 13 | parseAuthRequest, 14 | toMilliseconds, 15 | toSeconds, 16 | verifyPKCE, 17 | } from './utils.js'; 18 | import { exchangeCode, exchangeRefreshToken, upstreamAuth } from './client.js'; 19 | import { createNeonClient } from '../server/api.js'; 20 | import bodyParser from 'body-parser'; 21 | import { SERVER_HOST, COOKIE_SECRET } from '../constants.js'; 22 | import { 23 | isClientAlreadyApproved, 24 | updateApprovedClientsCookie, 25 | } from './cookies.js'; 26 | import { identify } from '../analytics/analytics.js'; 27 | 28 | const SUPPORTED_GRANT_TYPES = ['authorization_code', 'refresh_token']; 29 | const SUPPORTED_RESPONSE_TYPES = ['code']; 30 | const SUPPORTED_AUTH_METHODS = [ 31 | 'client_secret_post', 32 | 'client_secret_basic', 33 | 'none', 34 | ]; 35 | const SUPPORTED_CODE_CHALLENGE_METHODS = ['S256']; 36 | export const metadata = (req: ExpressRequest, res: ExpressResponse) => { 37 | res.json({ 38 | issuer: SERVER_HOST, 39 | authorization_endpoint: `${SERVER_HOST}/authorize`, 40 | token_endpoint: `${SERVER_HOST}/token`, 41 | registration_endpoint: `${SERVER_HOST}/register`, 42 | response_types_supported: SUPPORTED_RESPONSE_TYPES, 43 | response_modes_supported: ['query'], 44 | grant_types_supported: SUPPORTED_GRANT_TYPES, 45 | token_endpoint_auth_methods_supported: SUPPORTED_AUTH_METHODS, 46 | registration_endpoint_auth_methods_supported: SUPPORTED_AUTH_METHODS, 47 | code_challenge_methods_supported: SUPPORTED_CODE_CHALLENGE_METHODS, 48 | }); 49 | }; 50 | 51 | export const registerClient = async ( 52 | req: ExpressRequest, 53 | res: ExpressResponse, 54 | ) => { 55 | const payload = req.body; 56 | logger.info('request to register client: ', { 57 | name: payload.client_name, 58 | client_uri: payload.client_uri, 59 | }); 60 | 61 | if (payload.client_name === undefined) { 62 | res 63 | .status(400) 64 | .json({ code: 'invalid_request', error: 'client_name is required' }); 65 | return; 66 | } 67 | 68 | if (payload.redirect_uris === undefined) { 69 | res 70 | .status(400) 71 | .json({ code: 'invalid_request', error: 'redirect_uris is required' }); 72 | return; 73 | } 74 | 75 | if ( 76 | payload.grant_types === undefined || 77 | !payload.grant_types.every((grant: string) => 78 | SUPPORTED_GRANT_TYPES.includes(grant), 79 | ) 80 | ) { 81 | res.status(400).json({ 82 | code: 'invalid_request', 83 | error: 84 | 'grant_types is required and must only include supported grant types', 85 | }); 86 | return; 87 | } 88 | 89 | if ( 90 | payload.response_types === undefined || 91 | !payload.response_types.every((responseType: string) => 92 | SUPPORTED_RESPONSE_TYPES.includes(responseType), 93 | ) 94 | ) { 95 | res.status(400).json({ 96 | code: 'invalid_request', 97 | error: 98 | 'response_types is required and must only include supported response types', 99 | }); 100 | return; 101 | } 102 | 103 | try { 104 | const clientId = generateRandomString(8); 105 | const clientSecret = generateRandomString(32); 106 | const client: Client = { 107 | ...payload, 108 | id: clientId, 109 | secret: clientSecret, 110 | tokenEndpointAuthMethod: 111 | (req.body.token_endpoint_auth_method as string) ?? 'client_secret_post', 112 | registrationDate: Math.floor(Date.now() / 1000), 113 | }; 114 | 115 | await model.saveClient(client); 116 | logger.info('new client registered', { 117 | clientId, 118 | client_name: payload.client_name, 119 | redirect_uris: payload.redirect_uris, 120 | client_uri: payload.client_uri, 121 | }); 122 | 123 | res.json({ 124 | client_id: clientId, 125 | client_secret: clientSecret, 126 | client_name: payload.client_name, 127 | redirect_uris: payload.redirect_uris, 128 | token_endpoint_auth_method: client.tokenEndpointAuthMethod, 129 | }); 130 | } catch (error: unknown) { 131 | const message = error instanceof Error ? error.message : 'Unknown error'; 132 | logger.error('failed to register client:', { 133 | message, 134 | error, 135 | client: payload.client_name, 136 | client_uri: payload.client_uri, 137 | }); 138 | res.status(500).json({ code: 'server_error', error, message }); 139 | } 140 | }; 141 | 142 | const authRouter = express.Router(); 143 | authRouter.get('/.well-known/oauth-authorization-server', metadata); 144 | authRouter.post('/register', bodyParser.json(), registerClient); 145 | 146 | /* 147 | Initiate the authorization code grant flow by validating the request parameters and then redirecting to the upstream authorization server. 148 | 149 | Step 1: 150 | MCP client should invoke this endpoint with the following parameters: 151 | 152 | /authorize?client_id=clientId&redirect_uri=mcp://callback&response_type=code&scope=scope&code_challenge=codeChallenge&code_challenge_method=S256 153 | 154 | 155 | This endpoint will validate the `client_id` and other request parameters and then capture the parameters on `state` param and redirect to the upstream authorization server. 156 | */ 157 | authRouter.get( 158 | '/authorize', 159 | bodyParser.urlencoded({ extended: true }), 160 | async (req: ExpressRequest, res: ExpressResponse) => { 161 | const requestParams = parseAuthRequest(req); 162 | 163 | const clientId = requestParams.clientId; 164 | const client = await model.getClient(clientId, ''); 165 | if (!client) { 166 | res 167 | .status(400) 168 | .json({ code: 'invalid_request', error: 'invalid client id' }); 169 | return; 170 | } 171 | 172 | if ( 173 | requestParams.responseType == undefined || 174 | !client.response_types.includes(requestParams.responseType) 175 | ) { 176 | res 177 | .status(400) 178 | .json({ code: 'invalid_request', error: 'invalid response type' }); 179 | return; 180 | } 181 | 182 | if ( 183 | requestParams.redirectUri == undefined || 184 | !client.redirect_uris.includes(requestParams.redirectUri) 185 | ) { 186 | res 187 | .status(400) 188 | .json({ code: 'invalid_request', error: 'invalid redirect uri' }); 189 | return; 190 | } 191 | 192 | if (await isClientAlreadyApproved(req, client.id, COOKIE_SECRET)) { 193 | const authUrl = await upstreamAuth(btoa(JSON.stringify(requestParams))); 194 | res.redirect(authUrl.href); 195 | return; 196 | } 197 | 198 | res.render('approval-dialog', { 199 | client, 200 | state: btoa(JSON.stringify(requestParams)), 201 | }); 202 | }, 203 | ); 204 | 205 | authRouter.post( 206 | '/authorize', 207 | bodyParser.urlencoded({ extended: true }), 208 | async (req: ExpressRequest, res: ExpressResponse) => { 209 | const state = req.body.state as string; 210 | if (!state) { 211 | res.status(400).json({ code: 'invalid_request', error: 'invalid state' }); 212 | return; 213 | } 214 | 215 | const requestParams = JSON.parse(atob(state)); 216 | await updateApprovedClientsCookie( 217 | req, 218 | res, 219 | requestParams.clientId, 220 | COOKIE_SECRET, 221 | ); 222 | const authUrl = await upstreamAuth(state); 223 | res.redirect(authUrl.href); 224 | }, 225 | ); 226 | 227 | /* 228 | Handles the callback from the upstream authorization server and completes the authorization code grant flow with downstream MCP client. 229 | 230 | Step 2: 231 | Upstream authorization server will redirect to `/callback` with the authorization code. 232 | 233 | /callback?code=authorizationCode&state=state 234 | 235 | 236 | - Exchange the upstream authorization code for an access token. 237 | - Generate new authorization code and grant id. 238 | - Save the authorization code and access token in the database. 239 | - Redirect to the MCP client with the new authorization code. 240 | */ 241 | authRouter.get( 242 | '/callback', 243 | bodyParser.urlencoded({ extended: true }), 244 | async (req: ExpressRequest, res: ExpressResponse) => { 245 | const tokens = await exchangeCode(req); 246 | const state = req.query.state as string; 247 | const requestParams = decodeAuthParams(state); 248 | 249 | const clientId = requestParams.clientId; 250 | const client = await model.getClient(clientId, ''); 251 | if (!client) { 252 | res 253 | .status(400) 254 | .json({ code: 'invalid_request', error: 'invalid client id' }); 255 | return; 256 | } 257 | 258 | // Standard authorization code grant 259 | const grantId = generateRandomString(16); 260 | const nonce = generateRandomString(32); 261 | const authCode = `${grantId}:${nonce}`; 262 | 263 | // Get the user's info from Neon 264 | const neonClient = createNeonClient(tokens.access_token); 265 | const { data: user } = await neonClient.getCurrentUserInfo(); 266 | const expiresAt = Date.now() + toMilliseconds(tokens.expiresIn() ?? 0); 267 | // Save the authorization code with associated data 268 | const code: AuthorizationCode = { 269 | authorizationCode: authCode, 270 | expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes 271 | createdAt: Date.now(), 272 | redirectUri: requestParams.redirectUri, 273 | scope: requestParams.scope.join(' '), 274 | client: client, 275 | user: { 276 | id: user.id, 277 | email: user.email, 278 | name: `${user.name} ${user.last_name}`.trim(), 279 | }, 280 | token: { 281 | access_token: tokens.access_token, 282 | access_token_expires_at: expiresAt, 283 | refresh_token: tokens.refresh_token, 284 | id_token: tokens.id_token, 285 | }, 286 | code_challenge: requestParams.codeChallenge, 287 | code_challenge_method: requestParams.codeChallengeMethod, 288 | }; 289 | 290 | await model.saveAuthorizationCode(code); 291 | 292 | // Redirect back to client with auth code 293 | const redirectUrl = new URL(requestParams.redirectUri); 294 | redirectUrl.searchParams.set('code', authCode); 295 | if (requestParams.state) { 296 | redirectUrl.searchParams.set('state', requestParams.state); 297 | } 298 | 299 | res.redirect(redirectUrl.href); 300 | }, 301 | ); 302 | 303 | /* 304 | Handles the token exchange for `code` and `refresh_token` grant types with downstream MCP client. 305 | 306 | Step 3: 307 | MCP client should invoke this endpoint after receiving the authorization code to exchange for an access token. 308 | 309 | /token?client_id=clientId&grant_type=code&code=authorizationCode 310 | 311 | 312 | - Verify the authorization code, grant type and client 313 | - Save the access token and refresh token in the database for further API requests verification 314 | - Return with access token and refresh token 315 | */ 316 | authRouter.post( 317 | '/token', 318 | bodyParser.urlencoded({ extended: true }), 319 | async (req: ExpressRequest, res: ExpressResponse) => { 320 | const contentType = req.headers['content-type'] as string; 321 | if (contentType !== 'application/x-www-form-urlencoded') { 322 | res 323 | .status(415) 324 | .json({ code: 'invalid_request', error: 'invalid content type' }); 325 | return; 326 | } 327 | const { clientId, clientSecret } = extractClientCredentials(req); 328 | if (!clientId) { 329 | res 330 | .status(400) 331 | .json({ code: 'invalid_request', error: 'client_id is required' }); 332 | return; 333 | } 334 | 335 | const error = { 336 | error: 'invalid_client', 337 | error_description: 'client not found or invalid client credentials', 338 | }; 339 | const client = await model.getClient(clientId, ''); 340 | if (!client) { 341 | res.status(400).json({ code: 'invalid_request', ...error }); 342 | return; 343 | } 344 | 345 | const isPublicClient = client.tokenEndpointAuthMethod === 'none'; 346 | if (!isPublicClient) { 347 | if (clientSecret !== client.secret) { 348 | res.status(400).json({ code: 'invalid_request', ...error }); 349 | return; 350 | } 351 | } 352 | 353 | const formData = req.body; 354 | if (formData.grant_type === 'authorization_code') { 355 | const authorizationCode = await model.getAuthorizationCode(formData.code); 356 | if (!authorizationCode) { 357 | res.status(400).json({ 358 | code: 'invalid_request', 359 | error: 'invalid authorization code', 360 | }); 361 | return; 362 | } 363 | 364 | if (authorizationCode.client.id !== client.id) { 365 | res.status(400).json({ 366 | code: 'invalid_request', 367 | error: 'invalid authorization code', 368 | }); 369 | return; 370 | } 371 | 372 | if (authorizationCode.expiresAt < new Date()) { 373 | res.status(400).json({ 374 | code: 'invalid_request', 375 | error: 'authorization code expired', 376 | }); 377 | return; 378 | } 379 | 380 | const isPkceEnabled = authorizationCode.code_challenge !== undefined; 381 | if ( 382 | isPkceEnabled && 383 | !verifyPKCE( 384 | authorizationCode.code_challenge, 385 | authorizationCode.code_challenge_method, 386 | formData.code_verifier, 387 | ) 388 | ) { 389 | res.status(400).json({ 390 | code: 'invalid_grant', 391 | error: 'invalid PKCE code verifier', 392 | }); 393 | return; 394 | } 395 | if (!isPkceEnabled && !formData.redirect_uri) { 396 | res.status(400).json({ 397 | code: 'invalid_request', 398 | error: 'redirect_uri is required when not using PKCE', 399 | }); 400 | return; 401 | } 402 | if ( 403 | formData.redirect_uri && 404 | !client.redirect_uris.includes(formData.redirect_uri) 405 | ) { 406 | res.status(400).json({ 407 | code: 'invalid_request', 408 | error: 'invalid redirect uri', 409 | }); 410 | return; 411 | } 412 | 413 | // TODO: Generate fresh tokens and add mapping to database. 414 | const token = await model.saveToken({ 415 | accessToken: authorizationCode.token.access_token, 416 | refreshToken: authorizationCode.token.refresh_token, 417 | expires_at: authorizationCode.token.access_token_expires_at, 418 | client: client, 419 | user: authorizationCode.user, 420 | }); 421 | 422 | await model.saveRefreshToken({ 423 | refreshToken: token.refreshToken ?? '', 424 | accessToken: token.accessToken, 425 | }); 426 | 427 | identify( 428 | { 429 | id: authorizationCode.user.id, 430 | name: authorizationCode.user.name, 431 | email: authorizationCode.user.email, 432 | }, 433 | { 434 | context: { 435 | client: { 436 | id: client.id, 437 | name: client.client_name, 438 | }, 439 | }, 440 | }, 441 | ); 442 | 443 | // Revoke the authorization code, it can only be used once 444 | await model.revokeAuthorizationCode(authorizationCode); 445 | res.json({ 446 | access_token: token.accessToken, 447 | expires_in: toSeconds(token.expires_at - Date.now()), 448 | token_type: 'bearer', // TODO: Verify why non-bearer tokens are not working 449 | refresh_token: token.refreshToken, 450 | scope: authorizationCode.scope, 451 | }); 452 | return; 453 | } else if (formData.grant_type === 'refresh_token') { 454 | const providedRefreshToken = await model.getRefreshToken( 455 | formData.refresh_token, 456 | ); 457 | if (!providedRefreshToken) { 458 | res 459 | .status(400) 460 | .json({ code: 'invalid_request', error: 'invalid refresh token' }); 461 | return; 462 | } 463 | 464 | const oldToken = await model.getAccessToken( 465 | providedRefreshToken.accessToken, 466 | ); 467 | if (!oldToken) { 468 | // Refresh token is missing its counter access token, delete it 469 | await model.deleteRefreshToken(providedRefreshToken); 470 | res 471 | .status(400) 472 | .json({ code: 'invalid_request', error: 'invalid refresh token' }); 473 | return; 474 | } 475 | 476 | if (oldToken.client.id !== client.id) { 477 | res 478 | .status(400) 479 | .json({ code: 'invalid_request', error: 'invalid refresh token' }); 480 | return; 481 | } 482 | 483 | const upstreamToken = await exchangeRefreshToken( 484 | providedRefreshToken.refreshToken, 485 | ); 486 | const now = Date.now(); 487 | const expiresAt = now + toMilliseconds(upstreamToken.expiresIn() ?? 0); 488 | const token = await model.saveToken({ 489 | accessToken: upstreamToken.access_token, 490 | refreshToken: upstreamToken.refresh_token ?? '', 491 | expires_at: expiresAt, 492 | client: client, 493 | user: oldToken.user, 494 | }); 495 | await model.saveRefreshToken({ 496 | refreshToken: token.refreshToken ?? '', 497 | accessToken: token.accessToken, 498 | }); 499 | 500 | // Delete the old tokens 501 | await model.deleteToken(oldToken); 502 | await model.deleteRefreshToken(providedRefreshToken); 503 | 504 | res.json({ 505 | access_token: token.accessToken, 506 | expires_in: toSeconds(expiresAt - now), 507 | token_type: 'bearer', 508 | refresh_token: token.refreshToken, 509 | scope: oldToken.scope, 510 | }); 511 | return; 512 | } 513 | res 514 | .status(400) 515 | .json({ code: 'invalid_request', error: 'invalid grant type' }); 516 | }, 517 | ); 518 | 519 | export { authRouter }; 520 | -------------------------------------------------------------------------------- /src/oauth/utils.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import cors from 'cors'; 3 | import crypto from 'crypto'; 4 | import { model } from './model.js'; 5 | 6 | export const ensureCorsHeaders = () => 7 | cors({ 8 | origin: true, 9 | methods: '*', 10 | allowedHeaders: 'Authorization, Origin, Content-Type, Accept, *', 11 | }); 12 | 13 | export const requiresAuth = 14 | () => async (request: Request, response: Response, next: NextFunction) => { 15 | const authorization = request.headers.authorization; 16 | if (!authorization) { 17 | response.status(401).json({ error: 'Unauthorized' }); 18 | return; 19 | } 20 | 21 | const token = await model.getAccessToken(extractBearerToken(authorization)); 22 | if (!token) { 23 | response.status(401).json({ error: 'Invalid access token' }); 24 | return; 25 | } 26 | 27 | if (!token.expires_at || token.expires_at < Date.now()) { 28 | response.status(401).json({ error: 'Access token expired' }); 29 | return; 30 | } 31 | 32 | request.auth = { 33 | token: token.accessToken, 34 | clientId: token.client.id, 35 | scopes: Array.isArray(token.scope) 36 | ? token.scope 37 | : (token.scope?.split(' ') ?? []), 38 | extra: { 39 | user: { 40 | id: token.user.id, 41 | name: token.user.name, 42 | email: token.user.email, 43 | }, 44 | client: { 45 | id: token.client.id, 46 | name: token.client.client_name, 47 | }, 48 | }, 49 | }; 50 | 51 | next(); 52 | }; 53 | 54 | export type DownstreamAuthRequest = { 55 | responseType: string; 56 | clientId: string; 57 | redirectUri: string; 58 | scope: string[]; 59 | state: string; 60 | codeChallenge?: string; 61 | codeChallengeMethod?: string; 62 | }; 63 | 64 | export const parseAuthRequest = (request: Request): DownstreamAuthRequest => { 65 | const responseType = (request.query.response_type || '') as string; 66 | const clientId = (request.query.client_id || '') as string; 67 | const redirectUri = (request.query.redirect_uri || '') as string; 68 | const scope = (request.query.scope || '') as string; 69 | const state = (request.query.state || '') as string; 70 | const codeChallenge = (request.query.code_challenge as string) || undefined; 71 | const codeChallengeMethod = (request.query.code_challenge_method || 72 | 'plain') as string; 73 | 74 | return { 75 | responseType, 76 | clientId, 77 | redirectUri, 78 | scope: scope.split(' ').filter(Boolean), 79 | state, 80 | codeChallenge, 81 | codeChallengeMethod, 82 | }; 83 | }; 84 | 85 | export const decodeAuthParams = (state: string): DownstreamAuthRequest => { 86 | const decoded = atob(state); 87 | return JSON.parse(decoded); 88 | }; 89 | 90 | export const generateRandomString = (length: number): string => { 91 | const charset = 92 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 93 | const array = new Uint8Array(length); 94 | crypto.getRandomValues(array); 95 | return Array.from(array, (byte) => charset[byte % charset.length]).join(''); 96 | }; 97 | 98 | export const extractBearerToken = (authorizationHeader: string): string => { 99 | if (!authorizationHeader) return ''; 100 | return authorizationHeader.replace(/^Bearer\s+/i, ''); 101 | }; 102 | 103 | export const extractClientCredentials = (request: Request) => { 104 | const authorization = request.headers.authorization; 105 | if (authorization?.startsWith('Basic ')) { 106 | const credentials = atob(authorization.replace(/^Basic\s+/i, '')); 107 | const [clientId, clientSecret] = credentials.split(':'); 108 | return { clientId, clientSecret }; 109 | } 110 | 111 | return { 112 | clientId: request.body.client_id, 113 | clientSecret: request.body.client_secret, 114 | }; 115 | }; 116 | 117 | export const toSeconds = (ms: number): number => { 118 | return Math.floor(ms / 1000); 119 | }; 120 | 121 | export const toMilliseconds = (seconds: number): number => { 122 | return seconds * 1000; 123 | }; 124 | 125 | export const verifyPKCE = ( 126 | codeChallenge: string, 127 | codeChallengeMethod: string, 128 | codeVerifier: string, 129 | ): boolean => { 130 | if (!codeChallenge || !codeChallengeMethod || !codeVerifier) { 131 | return false; 132 | } 133 | 134 | if (codeChallengeMethod === 'S256') { 135 | const hash = crypto 136 | .createHash('sha256') 137 | .update(codeVerifier) 138 | .digest('base64url'); 139 | return codeChallenge === hash; 140 | } 141 | 142 | if (codeChallengeMethod === 'plain') { 143 | return codeChallenge === codeVerifier; 144 | } 145 | 146 | return false; 147 | }; 148 | -------------------------------------------------------------------------------- /src/resources.ts: -------------------------------------------------------------------------------- 1 | import { ReadResourceCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { Resource } from '@modelcontextprotocol/sdk/types.js'; 3 | 4 | async function fetchRawGithubContent(rawPath: string) { 5 | const path = rawPath.replace('/blob', ''); 6 | 7 | return fetch(`https://raw.githubusercontent.com${path}`).then((res) => 8 | res.text(), 9 | ); 10 | } 11 | 12 | export const NEON_RESOURCES = [ 13 | { 14 | name: 'neon-auth', 15 | uri: 'https://github.com/neondatabase-labs/ai-rules/blob/main/neon-auth.mdc', 16 | mimeType: 'text/plain', 17 | description: 'Neon Auth usage instructions', 18 | handler: async (url) => { 19 | const uri = url.host; 20 | const rawPath = url.pathname; 21 | const content = await fetchRawGithubContent(rawPath); 22 | return { 23 | contents: [ 24 | { 25 | uri: uri, 26 | mimeType: 'text/plain', 27 | text: content, 28 | }, 29 | ], 30 | }; 31 | }, 32 | }, 33 | { 34 | name: 'neon-serverless', 35 | uri: 'https://github.com/neondatabase-labs/ai-rules/blob/main/neon-serverless.mdc', 36 | mimeType: 'text/plain', 37 | description: 'Neon Serverless usage instructions', 38 | handler: async (url) => { 39 | const uri = url.host; 40 | const rawPath = url.pathname; 41 | const content = await fetchRawGithubContent(rawPath); 42 | return { 43 | contents: [ 44 | { 45 | uri, 46 | mimeType: 'text/plain', 47 | text: content, 48 | }, 49 | ], 50 | }; 51 | }, 52 | }, 53 | { 54 | name: 'neon-drizzle', 55 | uri: 'https://github.com/neondatabase-labs/ai-rules/blob/main/neon-drizzle.mdc', 56 | mimeType: 'text/plain', 57 | description: 'Neon Drizzle usage instructions', 58 | handler: async (url) => { 59 | const uri = url.host; 60 | const rawPath = url.pathname; 61 | const content = await fetchRawGithubContent(rawPath); 62 | return { 63 | contents: [ 64 | { 65 | uri, 66 | mimeType: 'text/plain', 67 | text: content, 68 | }, 69 | ], 70 | }; 71 | }, 72 | }, 73 | ] satisfies (Resource & { handler: ReadResourceCallback })[]; 74 | -------------------------------------------------------------------------------- /src/sentry/instrument.ts: -------------------------------------------------------------------------------- 1 | import { init } from '@sentry/node'; 2 | import { SENTRY_DSN } from '../constants.js'; 3 | import { getPackageJson } from '../server/api.js'; 4 | 5 | init({ 6 | dsn: SENTRY_DSN, 7 | environment: process.env.NODE_ENV, 8 | release: getPackageJson().version, 9 | tracesSampleRate: 1.0, 10 | 11 | // Setting this option to true will send default PII data to Sentry. 12 | // For example, automatic IP address collection on events 13 | sendDefaultPii: true, 14 | }); 15 | -------------------------------------------------------------------------------- /src/sentry/utils.ts: -------------------------------------------------------------------------------- 1 | import { setTags, setUser } from '@sentry/node'; 2 | import { ServerContext } from '../types/context.js'; 3 | 4 | export const setSentryTags = (context: ServerContext) => { 5 | setUser({ 6 | id: context.user.id, 7 | }); 8 | setTags({ 9 | 'app.name': context.app.name, 10 | 'app.version': context.app.version, 11 | 'app.transport': context.app.transport, 12 | 'app.environment': context.app.environment, 13 | }); 14 | if (context.client) { 15 | setTags({ 16 | 'client.id': context.client.id, 17 | 'client.name': context.client.name, 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/server/api.ts: -------------------------------------------------------------------------------- 1 | import { createApiClient } from '@neondatabase/api-client'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import fs from 'node:fs'; 5 | import { NEON_API_HOST } from '../constants.js'; 6 | 7 | export const getPackageJson = () => { 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | return JSON.parse( 10 | fs.readFileSync(path.join(__dirname, '../..', 'package.json'), 'utf8'), 11 | ); 12 | }; 13 | 14 | export const createNeonClient = (apiKey: string) => 15 | createApiClient({ 16 | apiKey, 17 | baseURL: NEON_API_HOST, 18 | headers: { 19 | 'User-Agent': `mcp-server-neon/${getPackageJson().version}`, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 4 | import { NEON_RESOURCES } from '../resources.js'; 5 | import { NEON_HANDLERS, NEON_TOOLS, ToolHandlerExtended } from '../tools.js'; 6 | import { logger } from '../utils/logger.js'; 7 | import { createNeonClient, getPackageJson } from './api.js'; 8 | import { track } from '../analytics/analytics.js'; 9 | import { captureException, startNewTrace, startSpan } from '@sentry/node'; 10 | import { ServerContext } from '../types/context.js'; 11 | import { setSentryTags } from '../sentry/utils.js'; 12 | 13 | export const createMcpServer = (context: ServerContext) => { 14 | const server = new McpServer( 15 | { 16 | name: 'mcp-server-neon', 17 | version: getPackageJson().version, 18 | }, 19 | { 20 | capabilities: { 21 | tools: {}, 22 | resources: {}, 23 | }, 24 | }, 25 | ); 26 | 27 | const neonClient = createNeonClient(context.apiKey); 28 | 29 | // Register tools 30 | NEON_TOOLS.forEach((tool) => { 31 | const handler = NEON_HANDLERS[tool.name]; 32 | if (!handler) { 33 | throw new Error(`Handler for tool ${tool.name} not found`); 34 | } 35 | 36 | const toolHandler = handler as ToolHandlerExtended; 37 | 38 | server.tool( 39 | tool.name, 40 | tool.description, 41 | // In case of no input parameters, the tool is invoked with an empty`{}` 42 | // however zod expects `{params: {}}` 43 | // To workaround this, we use `optional()` 44 | { params: tool.inputSchema.optional() }, 45 | async (args, extra) => { 46 | return await startNewTrace(async () => { 47 | return await startSpan( 48 | { 49 | name: 'tool_call', 50 | attributes: { 51 | tool_name: tool.name, 52 | }, 53 | }, 54 | async (span) => { 55 | const properties = { tool_name: tool.name }; 56 | logger.info('tool call:', properties); 57 | setSentryTags(context); 58 | track({ 59 | userId: context.user.id, 60 | event: 'tool_call', 61 | properties, 62 | context: { client: context.client, app: context.app }, 63 | }); 64 | try { 65 | // @ts-expect-error: Ignore zod optional 66 | return await toolHandler(args, neonClient, extra); 67 | } catch (error) { 68 | span.setStatus({ 69 | code: 2, 70 | }); 71 | captureException(error, { 72 | extra: properties, 73 | }); 74 | throw error; 75 | } 76 | }, 77 | ); 78 | }); 79 | }, 80 | ); 81 | }); 82 | 83 | // Register resources 84 | NEON_RESOURCES.forEach((resource) => { 85 | server.resource( 86 | resource.name, 87 | resource.uri, 88 | { 89 | description: resource.description, 90 | mimeType: resource.mimeType, 91 | }, 92 | async (url) => { 93 | const properties = { resource_name: resource.name }; 94 | logger.info('resource call:', properties); 95 | setSentryTags(context); 96 | track({ 97 | userId: context.user.id, 98 | event: 'resource_call', 99 | properties, 100 | context: { client: context.client, app: context.app }, 101 | }); 102 | try { 103 | return await resource.handler(url); 104 | } catch (error) { 105 | captureException(error, { 106 | extra: properties, 107 | }); 108 | throw error; 109 | } 110 | }, 111 | ); 112 | }); 113 | 114 | server.server.onerror = (error: unknown) => { 115 | const message = error instanceof Error ? error.message : 'Unknown error'; 116 | logger.error('Server error:', { 117 | message, 118 | error, 119 | }); 120 | const contexts = { app: context.app, client: context.client }; 121 | const eventId = captureException(error, { 122 | user: { id: context.user.id }, 123 | contexts: contexts, 124 | }); 125 | track({ 126 | userId: context.user.id, 127 | event: 'server_error', 128 | properties: { message, error, eventId }, 129 | context: contexts, 130 | }); 131 | }; 132 | 133 | return server; 134 | }; 135 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { Branch } from '@neondatabase/api-client'; 2 | 3 | type MigrationId = string; 4 | export type MigrationDetails = { 5 | migrationSql: string; 6 | databaseName: string; 7 | appliedBranch: Branch; 8 | roleName?: string; 9 | }; 10 | 11 | type TuningId = string; 12 | export type TuningDetails = { 13 | sql: string; 14 | databaseName: string; 15 | tuningBranch: Branch; 16 | roleName?: string; 17 | originalPlan?: any; 18 | suggestedChanges?: string[]; 19 | improvedPlan?: any; 20 | }; 21 | 22 | const migrationsState = new Map(); 23 | const tuningState = new Map(); 24 | 25 | export function getMigrationFromMemory(migrationId: string) { 26 | return migrationsState.get(migrationId); 27 | } 28 | 29 | export function persistMigrationToMemory( 30 | migrationId: string, 31 | migrationDetails: MigrationDetails, 32 | ) { 33 | migrationsState.set(migrationId, migrationDetails); 34 | } 35 | 36 | export function getTuningFromMemory(tuningId: string) { 37 | return tuningState.get(tuningId); 38 | } 39 | 40 | export function persistTuningToMemory( 41 | tuningId: string, 42 | tuningDetails: TuningDetails, 43 | ) { 44 | tuningState.set(tuningId, tuningDetails); 45 | } 46 | 47 | export function updateTuningInMemory( 48 | tuningId: string, 49 | updates: Partial, 50 | ) { 51 | const existing = tuningState.get(tuningId); 52 | if (existing) { 53 | tuningState.set(tuningId, { ...existing, ...updates }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/tools-evaluations/evalUtils.ts: -------------------------------------------------------------------------------- 1 | import { createApiClient } from '@neondatabase/api-client'; 2 | import path from 'path'; 3 | import { MCPClient } from '../../mcp-client/src/index.js'; 4 | 5 | export async function deleteNonDefaultBranches(projectId: string) { 6 | const neonClient = createApiClient({ 7 | apiKey: process.env.NEON_API_KEY!, 8 | }); 9 | 10 | try { 11 | const allBranches = await neonClient.listProjectBranches({ 12 | projectId: projectId, 13 | }); 14 | 15 | const branchesToDelete = allBranches.data.branches.filter( 16 | (b) => !b.default, 17 | ); 18 | 19 | await Promise.all( 20 | branchesToDelete.map((b) => 21 | neonClient.deleteProjectBranch(b.project_id, b.id), 22 | ), 23 | ); 24 | } catch (e) { 25 | console.error(e); 26 | } 27 | } 28 | 29 | export async function evaluateTask(input: string) { 30 | const client = new MCPClient({ 31 | command: path.resolve(__dirname, '../../dist/index.js'), 32 | args: ['start', process.env.NEON_API_KEY!], 33 | loggerOptions: { 34 | mode: 'error', 35 | }, 36 | }); 37 | 38 | await client.start(); 39 | const response = await client.processQuery(input); 40 | await client.stop(); 41 | 42 | if (!response) { 43 | throw new Error('No response from MCP Client'); 44 | } 45 | 46 | return response; 47 | } 48 | -------------------------------------------------------------------------------- /src/tools-evaluations/prepare-database-migration.eval.ts: -------------------------------------------------------------------------------- 1 | import { Eval, EvalCase, Reporter, reportFailures } from 'braintrust'; 2 | import { LLMClassifierFromTemplate } from 'autoevals'; 3 | 4 | import { createApiClient } from '@neondatabase/api-client'; 5 | import { deleteNonDefaultBranches, evaluateTask } from './evalUtils'; 6 | 7 | const EVAL_INFO = { 8 | projectId: 'black-recipe-75251165', 9 | roleName: 'neondb_owner', 10 | databaseName: 'neondb', 11 | mainBranchId: 'br-cold-bird-a5icgh5h', 12 | }; 13 | 14 | const getMainBranchDatabaseSchema = async () => { 15 | const neonClient = createApiClient({ 16 | apiKey: process.env.NEON_API_KEY!, 17 | }); 18 | 19 | const dbSchema = await neonClient.getProjectBranchSchema({ 20 | projectId: EVAL_INFO.projectId, 21 | branchId: EVAL_INFO.mainBranchId, 22 | db_name: EVAL_INFO.databaseName, 23 | }); 24 | 25 | return dbSchema.data.sql; 26 | }; 27 | 28 | const factualityAnthropic = LLMClassifierFromTemplate({ 29 | name: 'Factuality Anthropic', 30 | promptTemplate: ` 31 | You are comparing a submitted answer to an expert answer on a given question. Here is the data: 32 | [BEGIN DATA] 33 | ************ 34 | [Question]: {{{input}}} 35 | ************ 36 | [Expert]: {{{expected}}} 37 | ************ 38 | [Submission]: {{{output}}} 39 | ************ 40 | [END DATA] 41 | 42 | Compare the factual content of the submitted answer with the expert answer. 43 | Implementation details like specific IDs, or exact formatting should be considered non-factual differences. 44 | 45 | Ignore the following differences: 46 | - Specific migration IDs or references 47 | - Formatting or structural variations 48 | - Order of presenting the information 49 | - Restatements of the same request/question 50 | - Additional confirmatory language that doesn't add new information 51 | 52 | The submitted answer may either be: 53 | (A) A subset missing key factual information from the expert answer 54 | (B) A superset that FIRST agrees with the expert answer's core facts AND THEN adds additional factual information 55 | (C) Factually equivalent to the expert answer 56 | (D) In factual disagreement with or takes a completely different action than the expert answer 57 | (E) Different only in non-factual implementation details 58 | 59 | Select the most appropriate option, prioritizing the core factual content over implementation specifics. 60 | `, 61 | choiceScores: { 62 | A: 0.4, 63 | B: 0.8, 64 | C: 1, 65 | D: 0, 66 | E: 1, 67 | }, 68 | temperature: 0, 69 | useCoT: true, 70 | model: 'claude-3-5-sonnet-20241022', 71 | }); 72 | 73 | const mainBranchIntegrityCheck = async (args: { 74 | input: string; 75 | output: string; 76 | expected: string; 77 | metadata?: { 78 | databaseSchemaBeforeRun: string; 79 | databaseSchemaAfterRun: string; 80 | }; 81 | }) => { 82 | const databaseSchemaBeforeRun = args.metadata?.databaseSchemaBeforeRun; 83 | const databaseSchemaAfterRun = args.metadata?.databaseSchemaAfterRun; 84 | const databaseSchemaAfterRunResponseIsComplete = 85 | databaseSchemaAfterRun?.includes('PostgreSQL database dump complete') ?? 86 | false; 87 | 88 | // sometimes the pg_dump fails to deliver the full responses, which leads to false negatives 89 | // so we must eject 90 | if (!databaseSchemaAfterRunResponseIsComplete) { 91 | return null; 92 | } 93 | 94 | const isSame = databaseSchemaBeforeRun === databaseSchemaAfterRun; 95 | 96 | return { 97 | name: 'Main Branch Integrity Check', 98 | score: isSame ? 1 : 0, 99 | }; 100 | }; 101 | 102 | Eval('prepare_database_migration', { 103 | data: (): EvalCase< 104 | string, 105 | string, 106 | | { 107 | databaseSchemaBeforeRun: string; 108 | databaseSchemaAfterRun: string; 109 | } 110 | | undefined 111 | >[] => { 112 | return [ 113 | // Add column 114 | { 115 | input: `in my ${EVAL_INFO.projectId} project, add a new column Description to the posts table`, 116 | expected: ` 117 | I've verified that the Description column has been successfully added to the posts table in a temporary branch. Would you like to commit the migration to the main branch? 118 | 119 | Migration Details: 120 | - Migration ID: 121 | - Temporary Branch Name: 122 | - Temporary Branch ID: 123 | - Migration Result: 124 | `, 125 | }, 126 | 127 | // Add column with different type 128 | { 129 | input: `in my ${EVAL_INFO.projectId} project, add view_count column to posts table`, 130 | expected: ` 131 | I've verified that the view_count column has been successfully added to the posts table in a temporary branch. Would you like to commit the migration to the main branch? 132 | 133 | Migration Details: 134 | - Migration ID: 135 | - Temporary Branch Name: 136 | - Temporary Branch ID: 137 | - Migration Result: 138 | `, 139 | }, 140 | 141 | // Rename column 142 | { 143 | input: `in my ${EVAL_INFO.projectId} project, rename the content column to body in posts table`, 144 | expected: ` 145 | I've verified that the content column has been successfully renamed to body in the posts table in a temporary branch. Would you like to commit the migration to the main branch? 146 | 147 | Migration Details: 148 | - Migration ID: 149 | - Temporary Branch Name: 150 | - Temporary Branch ID: 151 | - Migration Result: 152 | `, 153 | }, 154 | 155 | // Add index 156 | { 157 | input: `in my ${EVAL_INFO.projectId} project, create an index on title column in posts table`, 158 | expected: ` 159 | I've verified that the index has been successfully created on the title column in the posts table in a temporary branch. Would you like to commit the migration to the main branch? 160 | 161 | Migration Details: 162 | - Migration ID: 163 | - Temporary Branch Name: 164 | - Temporary Branch ID: 165 | - Migration Result: 166 | `, 167 | }, 168 | 169 | // Drop column 170 | { 171 | input: `in my ${EVAL_INFO.projectId} project, drop the content column from posts table`, 172 | expected: ` 173 | I've verified that the content column has been successfully dropped from the posts table in a temporary branch. Would you like to commit the migration to the main branch? 174 | 175 | Migration Details: 176 | - Migration ID: 177 | - Temporary Branch Name: 178 | - Temporary Branch ID: 179 | - Migration Result: 180 | `, 181 | }, 182 | 183 | // Alter column type 184 | { 185 | input: `in my ${EVAL_INFO.projectId} project, change the title column type to text in posts table`, 186 | expected: ` 187 | I've verified that the data type of the title column has been successfully changed in the posts table in a temporary branch. Would you like to commit the migration to the main branch? 188 | 189 | Migration Details: 190 | - Migration ID: 191 | - Temporary Branch Name: 192 | - Temporary Branch ID: 193 | - Migration Result: 194 | `, 195 | }, 196 | 197 | // Add boolean column 198 | { 199 | input: `in my ${EVAL_INFO.projectId} project, add is_published column to posts table`, 200 | expected: ` 201 | I've verified that the is_published column has been successfully added to the posts table in a temporary branch. Would you like to commit the migration to the main branch? 202 | 203 | Migration Details: 204 | - Migration ID: 205 | - Temporary Branch Name: 206 | - Temporary Branch ID: 207 | - Migration Result: 208 | `, 209 | }, 210 | 211 | // Add numeric column 212 | { 213 | input: `in my ${EVAL_INFO.projectId} project, add likes_count column to posts table`, 214 | expected: ` 215 | I've verified that the likes_count column has been successfully added to the posts table in a temporary branch. Would you like to commit the migration to the main branch? 216 | 217 | Migration Details: 218 | - Migration ID: 219 | - Temporary Branch Name: 220 | - Temporary Branch ID: 221 | - Migration Result: 222 | `, 223 | }, 224 | 225 | // Create index 226 | { 227 | input: `in my ${EVAL_INFO.projectId} project, create index on title column in posts table`, 228 | expected: ` 229 | I've verified that the index has been successfully created on the title column in the posts table in a temporary branch. Would you like to commit the migration to the main branch? 230 | 231 | Migration Details: 232 | - Migration ID: 233 | - Temporary Branch Name: 234 | - Temporary Branch ID: 235 | - Migration Result: 236 | `, 237 | }, 238 | ]; 239 | }, 240 | task: async (input, hooks) => { 241 | const databaseSchemaBeforeRun = await getMainBranchDatabaseSchema(); 242 | hooks.metadata.databaseSchemaBeforeRun = databaseSchemaBeforeRun; 243 | 244 | const llmCallMessages = await evaluateTask(input); 245 | 246 | const databaseSchemaAfterRun = await getMainBranchDatabaseSchema(); 247 | hooks.metadata.databaseSchemaAfterRun = databaseSchemaAfterRun; 248 | hooks.metadata.llmCallMessages = llmCallMessages; 249 | 250 | deleteNonDefaultBranches(EVAL_INFO.projectId); 251 | 252 | const finalMessage = llmCallMessages[llmCallMessages.length - 1]; 253 | return finalMessage.content; 254 | }, 255 | trialCount: 20, 256 | maxConcurrency: 2, 257 | scores: [factualityAnthropic, mainBranchIntegrityCheck], 258 | }); 259 | 260 | Reporter('Prepare Database Migration Reporter', { 261 | reportEval: async (evaluator, result, { verbose, jsonl }) => { 262 | const { results, summary } = result; 263 | const failingResults = results.filter( 264 | (r: { error: unknown }) => r.error !== undefined, 265 | ); 266 | 267 | if (failingResults.length > 0) { 268 | reportFailures(evaluator, failingResults, { verbose, jsonl }); 269 | } 270 | 271 | console.log(jsonl ? JSON.stringify(summary) : summary); 272 | return failingResults.length === 0; 273 | }, 274 | 275 | // cleanup branches after the run 276 | reportRun: async (evalReports) => { 277 | await deleteNonDefaultBranches(EVAL_INFO.projectId); 278 | 279 | return evalReports.every((r) => r); 280 | }, 281 | }); 282 | -------------------------------------------------------------------------------- /src/toolsSchema.ts: -------------------------------------------------------------------------------- 1 | import { ListProjectsParams } from '@neondatabase/api-client'; 2 | import { z } from 'zod'; 3 | import { NEON_DEFAULT_DATABASE_NAME } from './constants.js'; 4 | 5 | type ZodObjectParams = z.ZodObject<{ [key in keyof T]: z.ZodType }>; 6 | 7 | const DATABASE_NAME_DESCRIPTION = `The name of the database. If not provided, the default ${NEON_DEFAULT_DATABASE_NAME} or first available database is used.`; 8 | 9 | export const listProjectsInputSchema = z.object({ 10 | cursor: z 11 | .string() 12 | .optional() 13 | .describe( 14 | 'Specify the cursor value from the previous response to retrieve the next batch of projects.', 15 | ), 16 | limit: z 17 | .number() 18 | .default(10) 19 | .describe( 20 | 'Specify a value from 1 to 400 to limit number of projects in the response.', 21 | ), 22 | search: z 23 | .string() 24 | .optional() 25 | .describe( 26 | 'Search by project name or id. You can specify partial name or id values to filter results.', 27 | ), 28 | org_id: z.string().optional().describe('Search for projects by org_id.'), 29 | }) satisfies ZodObjectParams; 30 | 31 | export const createProjectInputSchema = z.object({ 32 | name: z 33 | .string() 34 | .optional() 35 | .describe('An optional name of the project to create.'), 36 | org_id: z 37 | .string() 38 | .optional() 39 | .describe('Create project in a specific organization.'), 40 | }); 41 | 42 | export const deleteProjectInputSchema = z.object({ 43 | projectId: z.string().describe('The ID of the project to delete'), 44 | }); 45 | 46 | export const describeProjectInputSchema = z.object({ 47 | projectId: z.string().describe('The ID of the project to describe'), 48 | }); 49 | 50 | export const runSqlInputSchema = z.object({ 51 | sql: z.string().describe('The SQL query to execute'), 52 | projectId: z 53 | .string() 54 | .describe('The ID of the project to execute the query against'), 55 | branchId: z 56 | .string() 57 | .optional() 58 | .describe( 59 | 'An optional ID of the branch to execute the query against. If not provided the default branch is used.', 60 | ), 61 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 62 | }); 63 | 64 | export const runSqlTransactionInputSchema = z.object({ 65 | sqlStatements: z.array(z.string()).describe('The SQL statements to execute'), 66 | projectId: z 67 | .string() 68 | .describe('The ID of the project to execute the query against'), 69 | branchId: z 70 | .string() 71 | .optional() 72 | .describe( 73 | 'An optional ID of the branch to execute the query against. If not provided the default branch is used.', 74 | ), 75 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 76 | }); 77 | 78 | export const explainSqlStatementInputSchema = z.object({ 79 | sql: z.string().describe('The SQL statement to analyze'), 80 | projectId: z 81 | .string() 82 | .describe('The ID of the project to execute the query against'), 83 | branchId: z 84 | .string() 85 | .optional() 86 | .describe( 87 | 'An optional ID of the branch to execute the query against. If not provided the default branch is used.', 88 | ), 89 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 90 | analyze: z 91 | .boolean() 92 | .default(true) 93 | .describe('Whether to include ANALYZE in the EXPLAIN command'), 94 | }); 95 | export const describeTableSchemaInputSchema = z.object({ 96 | tableName: z.string().describe('The name of the table'), 97 | projectId: z 98 | .string() 99 | .describe('The ID of the project to execute the query against'), 100 | branchId: z 101 | .string() 102 | .optional() 103 | .describe( 104 | 'An optional ID of the branch to execute the query against. If not provided the default branch is used.', 105 | ), 106 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 107 | }); 108 | 109 | export const getDatabaseTablesInputSchema = z.object({ 110 | projectId: z.string().describe('The ID of the project'), 111 | branchId: z 112 | .string() 113 | .optional() 114 | .describe( 115 | 'An optional ID of the branch. If not provided the default branch is used.', 116 | ), 117 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 118 | }); 119 | 120 | export const createBranchInputSchema = z.object({ 121 | projectId: z 122 | .string() 123 | .describe('The ID of the project to create the branch in'), 124 | branchName: z.string().optional().describe('An optional name for the branch'), 125 | }); 126 | 127 | export const prepareDatabaseMigrationInputSchema = z.object({ 128 | migrationSql: z 129 | .string() 130 | .describe('The SQL to execute to create the migration'), 131 | projectId: z 132 | .string() 133 | .describe('The ID of the project to execute the query against'), 134 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 135 | }); 136 | 137 | export const completeDatabaseMigrationInputSchema = z.object({ 138 | migrationId: z.string(), 139 | }); 140 | 141 | export const describeBranchInputSchema = z.object({ 142 | projectId: z.string().describe('The ID of the project'), 143 | branchId: z.string().describe('An ID of the branch to describe'), 144 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 145 | }); 146 | 147 | export const deleteBranchInputSchema = z.object({ 148 | projectId: z.string().describe('The ID of the project containing the branch'), 149 | branchId: z.string().describe('The ID of the branch to delete'), 150 | }); 151 | 152 | export const getConnectionStringInputSchema = z.object({ 153 | projectId: z 154 | .string() 155 | .describe( 156 | 'The ID of the project. If not provided, the only available project will be used.', 157 | ), 158 | branchId: z 159 | .string() 160 | .optional() 161 | .describe( 162 | 'The ID or name of the branch. If not provided, the default branch will be used.', 163 | ), 164 | computeId: z 165 | .string() 166 | .optional() 167 | .describe( 168 | 'The ID of the compute/endpoint. If not provided, the read-write compute associated with the branch will be used.', 169 | ), 170 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 171 | roleName: z 172 | .string() 173 | .optional() 174 | .describe( 175 | 'The name of the role to connect with. If not provided, the database owner name will be used.', 176 | ), 177 | }); 178 | 179 | export const provisionNeonAuthInputSchema = z.object({ 180 | projectId: z 181 | .string() 182 | .describe('The ID of the project to provision Neon Auth for'), 183 | database: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 184 | }); 185 | 186 | export const prepareQueryTuningInputSchema = z.object({ 187 | sql: z.string().describe('The SQL statement to analyze and tune'), 188 | databaseName: z 189 | .string() 190 | .describe('The name of the database to execute the query against'), 191 | projectId: z 192 | .string() 193 | .describe('The ID of the project to execute the query against'), 194 | roleName: z 195 | .string() 196 | .optional() 197 | .describe( 198 | 'The name of the role to connect with. If not provided, the default role (usually "neondb_owner") will be used.', 199 | ), 200 | }); 201 | 202 | export const completeQueryTuningInputSchema = z.object({ 203 | suggestedSqlStatements: z 204 | .array(z.string()) 205 | .describe( 206 | 'The SQL DDL statements to execute to improve performance. These statements are the result of the prior steps, for example creating additional indexes.', 207 | ), 208 | applyChanges: z 209 | .boolean() 210 | .default(false) 211 | .describe('Whether to apply the suggested changes to the main branch'), 212 | tuningId: z 213 | .string() 214 | .describe( 215 | 'The ID of the tuning to complete. This is NOT the branch ID. Remember this ID from the prior step using tool prepare_query_tuning.', 216 | ), 217 | databaseName: z 218 | .string() 219 | .describe('The name of the database to execute the query against'), 220 | projectId: z 221 | .string() 222 | .describe('The ID of the project to execute the query against'), 223 | roleName: z 224 | .string() 225 | .optional() 226 | .describe( 227 | 'The name of the role to connect with. If you have used a specific role in prepare_query_tuning you MUST pass the same role again to this tool. If not provided, the default role (usually "neondb_owner") will be used.', 228 | ), 229 | shouldDeleteTemporaryBranch: z 230 | .boolean() 231 | .default(true) 232 | .describe('Whether to delete the temporary branch after tuning'), 233 | temporaryBranchId: z 234 | .string() 235 | .describe( 236 | 'The ID of the temporary branch that needs to be deleted after tuning.', 237 | ), 238 | branchId: z 239 | .string() 240 | .optional() 241 | .describe( 242 | 'The ID or name of the branch that receives the changes. If not provided, the default (main) branch will be used.', 243 | ), 244 | }); 245 | 246 | export const listSlowQueriesInputSchema = z.object({ 247 | projectId: z 248 | .string() 249 | .describe('The ID of the project to list slow queries from'), 250 | branchId: z 251 | .string() 252 | .optional() 253 | .describe( 254 | 'An optional ID of the branch. If not provided the default branch is used.', 255 | ), 256 | databaseName: z.string().optional().describe(DATABASE_NAME_DESCRIPTION), 257 | computeId: z 258 | .string() 259 | .optional() 260 | .describe( 261 | 'The ID of the compute/endpoint. If not provided, the read-write compute associated with the branch will be used.', 262 | ), 263 | limit: z 264 | .number() 265 | .optional() 266 | .default(10) 267 | .describe('Maximum number of slow queries to return'), 268 | minExecutionTime: z 269 | .number() 270 | .optional() 271 | .default(1000) 272 | .describe( 273 | 'Minimum execution time in milliseconds to consider a query as slow', 274 | ), 275 | }); 276 | 277 | export const listBranchComputesInputSchema = z.object({ 278 | projectId: z 279 | .string() 280 | .optional() 281 | .describe( 282 | 'The ID of the project. If not provided, the only available project will be used.', 283 | ), 284 | branchId: z 285 | .string() 286 | .optional() 287 | .describe( 288 | 'The ID of the branch. If provided, endpoints for this specific branch will be listed.', 289 | ), 290 | }); 291 | -------------------------------------------------------------------------------- /src/transports/sse-express.ts: -------------------------------------------------------------------------------- 1 | import '../sentry/instrument.js'; 2 | import { setupExpressErrorHandler } from '@sentry/node'; 3 | import express, { Request, Response, RequestHandler } from 'express'; 4 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 5 | import { createMcpServer } from '../server/index.js'; 6 | import { createNeonClient } from '../server/api.js'; 7 | import { logger, morganConfig, errorHandler } from '../utils/logger.js'; 8 | import { authRouter } from '../oauth/server.js'; 9 | import { SERVER_PORT, SERVER_HOST } from '../constants.js'; 10 | import { ensureCorsHeaders, requiresAuth } from '../oauth/utils.js'; 11 | import bodyParser from 'body-parser'; 12 | import cookieParser from 'cookie-parser'; 13 | import { track } from '../analytics/analytics.js'; 14 | import { AppContext } from '../types/context.js'; 15 | 16 | export const createSseTransport = (appContext: AppContext) => { 17 | const app = express(); 18 | 19 | app.use(morganConfig); 20 | app.use(errorHandler); 21 | app.use(cookieParser()); 22 | app.use(ensureCorsHeaders()); 23 | app.use(express.static('public')); 24 | app.set('view engine', 'pug'); 25 | app.set('views', 'src/views'); 26 | app.use('/', authRouter); 27 | 28 | // to support multiple simultaneous connections we have a lookup object from 29 | // sessionId to transport 30 | const transports = new Map(); 31 | 32 | app.get('/', (async (req: Request, res: Response) => { 33 | const auth = req.auth; 34 | if (!auth) { 35 | res.status(401).send('Unauthorized'); 36 | return; 37 | } 38 | 39 | const neonClient = createNeonClient(auth.token); 40 | const user = await neonClient.getCurrentUserInfo(); 41 | res.send({ 42 | hello: `${user.data.name} ${user.data.last_name}`.trim(), 43 | }); 44 | }) as RequestHandler); 45 | 46 | app.get( 47 | '/sse', 48 | bodyParser.raw(), 49 | requiresAuth(), 50 | async (req: Request, res: Response) => { 51 | const auth = req.auth; 52 | if (!auth) { 53 | res.status(401).send('Unauthorized'); 54 | return; 55 | } 56 | const transport = new SSEServerTransport('/messages', res); 57 | transports.set(transport.sessionId, transport); 58 | logger.info('new sse connection', { 59 | sessionId: transport.sessionId, 60 | }); 61 | 62 | res.on('close', () => { 63 | logger.info('SSE connection closed', { 64 | sessionId: transport.sessionId, 65 | }); 66 | transports.delete(transport.sessionId); 67 | }); 68 | 69 | try { 70 | const server = createMcpServer({ 71 | apiKey: auth.token, 72 | client: auth.extra.client, 73 | user: auth.extra.user, 74 | app: appContext, 75 | }); 76 | await server.connect(transport); 77 | } catch (error: unknown) { 78 | logger.error('Failed to connect to MCP server:', { 79 | message: error instanceof Error ? error.message : 'Unknown error', 80 | error, 81 | }); 82 | track({ 83 | userId: auth.extra.user.id, 84 | event: 'sse_connection_errored', 85 | properties: { error }, 86 | context: { 87 | app: appContext, 88 | client: auth.extra.client, 89 | }, 90 | }); 91 | } 92 | }, 93 | ); 94 | 95 | app.post('/messages', bodyParser.raw(), requiresAuth(), (async ( 96 | request: Request, 97 | response: Response, 98 | ) => { 99 | const auth = request.auth; 100 | if (!auth) { 101 | response.status(401).send('Unauthorized'); 102 | return; 103 | } 104 | const sessionId = request.query.sessionId as string; 105 | const transport = transports.get(sessionId); 106 | logger.info('transport message received', { 107 | sessionId, 108 | hasTransport: Boolean(transport), 109 | }); 110 | 111 | try { 112 | if (transport) { 113 | await transport.handlePostMessage(request, response); 114 | } else { 115 | logger.warn('No transport found for sessionId', { sessionId }); 116 | response.status(400).send('No transport found for sessionId'); 117 | } 118 | } catch (error: unknown) { 119 | logger.error('Failed to handle post message:', { 120 | message: error instanceof Error ? error.message : 'Unknown error', 121 | error, 122 | }); 123 | track({ 124 | userId: auth.extra.user.id, 125 | event: 'transport_message_errored', 126 | properties: { error }, 127 | context: { app: appContext, client: auth.extra.client }, 128 | }); 129 | } 130 | }) as RequestHandler); 131 | 132 | setupExpressErrorHandler(app); 133 | 134 | try { 135 | app.listen({ port: SERVER_PORT }); 136 | logger.info(`Server started on ${SERVER_HOST}`); 137 | } catch (err: unknown) { 138 | logger.error('Failed to start server:', { 139 | error: err instanceof Error ? err.message : 'Unknown error', 140 | }); 141 | process.exit(1); 142 | } 143 | }; 144 | -------------------------------------------------------------------------------- /src/transports/stdio.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 4 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | /** 6 | * Start the server using stdio transport. 7 | * This allows the server to communicate via standard input/output streams. 8 | */ 9 | export const startStdio = async (server: McpServer) => { 10 | const transport = new StdioServerTransport(); 11 | await server.connect(transport); 12 | }; 13 | -------------------------------------------------------------------------------- /src/types/auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; 2 | 3 | export type AuthContext = { 4 | extra: { 5 | user: { 6 | id: string; 7 | name: string; 8 | email: string; 9 | }; 10 | client: { 11 | id: string; 12 | name: string; 13 | }; 14 | [key: string]: unknown; 15 | }; 16 | } & AuthInfo; 17 | -------------------------------------------------------------------------------- /src/types/context.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '../constants.js'; 2 | import { AuthContext } from './auth.js'; 3 | 4 | export type AppContext = { 5 | name: string; 6 | transport: 'sse' | 'stdio'; 7 | environment: Environment; 8 | version: string; 9 | }; 10 | 11 | export type ServerContext = { 12 | apiKey: string; 13 | client?: AuthContext['extra']['client']; 14 | user: AuthContext['extra']['user']; 15 | app: AppContext; 16 | }; 17 | -------------------------------------------------------------------------------- /src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import { AuthContext } from './auth.js'; 2 | 3 | // to make the file a module and avoid the TypeScript error 4 | export {}; 5 | 6 | // Extends the Express Request interface to add the auth context 7 | declare global { 8 | namespace Express { 9 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 10 | export interface Request { 11 | auth?: AuthContext; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { NEON_DEFAULT_DATABASE_NAME } from './constants.js'; 2 | import { Api, Organization } from '@neondatabase/api-client'; 3 | 4 | export const splitSqlStatements = (sql: string) => { 5 | return sql.split(';').filter(Boolean); 6 | }; 7 | 8 | export const DESCRIBE_DATABASE_STATEMENTS = [ 9 | ` 10 | CREATE OR REPLACE FUNCTION public.show_db_tree() 11 | RETURNS TABLE (tree_structure text) AS 12 | $$ 13 | BEGIN 14 | -- First show all databases 15 | RETURN QUERY 16 | SELECT ':file_folder: ' || datname || ' (DATABASE)' 17 | FROM pg_database 18 | WHERE datistemplate = false; 19 | 20 | -- Then show current database structure 21 | RETURN QUERY 22 | WITH RECURSIVE 23 | -- Get schemas 24 | schemas AS ( 25 | SELECT 26 | n.nspname AS object_name, 27 | 1 AS level, 28 | n.nspname AS path, 29 | 'SCHEMA' AS object_type 30 | FROM pg_namespace n 31 | WHERE n.nspname NOT LIKE 'pg_%' 32 | AND n.nspname != 'information_schema' 33 | ), 34 | 35 | -- Get all objects (tables, views, functions, etc.) 36 | objects AS ( 37 | SELECT 38 | c.relname AS object_name, 39 | 2 AS level, 40 | s.path || ' → ' || c.relname AS path, 41 | CASE c.relkind 42 | WHEN 'r' THEN 'TABLE' 43 | WHEN 'v' THEN 'VIEW' 44 | WHEN 'm' THEN 'MATERIALIZED VIEW' 45 | WHEN 'i' THEN 'INDEX' 46 | WHEN 'S' THEN 'SEQUENCE' 47 | WHEN 'f' THEN 'FOREIGN TABLE' 48 | END AS object_type 49 | FROM pg_class c 50 | JOIN pg_namespace n ON n.oid = c.relnamespace 51 | JOIN schemas s ON n.nspname = s.object_name 52 | WHERE c.relkind IN ('r','v','m','i','S','f') 53 | 54 | UNION ALL 55 | 56 | SELECT 57 | p.proname AS object_name, 58 | 2 AS level, 59 | s.path || ' → ' || p.proname AS path, 60 | 'FUNCTION' AS object_type 61 | FROM pg_proc p 62 | JOIN pg_namespace n ON n.oid = p.pronamespace 63 | JOIN schemas s ON n.nspname = s.object_name 64 | ), 65 | 66 | -- Combine schemas and objects 67 | combined AS ( 68 | SELECT * FROM schemas 69 | UNION ALL 70 | SELECT * FROM objects 71 | ) 72 | 73 | -- Final output with tree-like formatting 74 | SELECT 75 | REPEAT(' ', level) || 76 | CASE 77 | WHEN level = 1 THEN '└── :open_file_folder: ' 78 | ELSE ' └── ' || 79 | CASE object_type 80 | WHEN 'TABLE' THEN ':bar_chart: ' 81 | WHEN 'VIEW' THEN ':eye: ' 82 | WHEN 'MATERIALIZED VIEW' THEN ':newspaper: ' 83 | WHEN 'FUNCTION' THEN ':zap: ' 84 | WHEN 'INDEX' THEN ':mag: ' 85 | WHEN 'SEQUENCE' THEN ':1234: ' 86 | WHEN 'FOREIGN TABLE' THEN ':globe_with_meridians: ' 87 | ELSE '' 88 | END 89 | END || object_name || ' (' || object_type || ')' 90 | FROM combined 91 | ORDER BY path; 92 | END; 93 | $$ LANGUAGE plpgsql; 94 | `, 95 | ` 96 | -- To use the function: 97 | SELECT * FROM show_db_tree(); 98 | `, 99 | ]; 100 | 101 | /** 102 | * Returns the default database for a project branch 103 | * If a database name is provided, it fetches and returns that database 104 | * Otherwise, it looks for a database named 'neondb' and returns that 105 | * If 'neondb' doesn't exist, it returns the first available database 106 | * Throws an error if no databases are found 107 | */ 108 | export async function getDefaultDatabase( 109 | { 110 | projectId, 111 | branchId, 112 | databaseName, 113 | }: { 114 | projectId: string; 115 | branchId: string; 116 | databaseName?: string; 117 | }, 118 | neonClient: Api, 119 | ) { 120 | const { data } = await neonClient.listProjectBranchDatabases( 121 | projectId, 122 | branchId, 123 | ); 124 | const databases = data.databases; 125 | if (databases.length === 0) { 126 | throw new Error('No databases found in your project branch'); 127 | } 128 | 129 | if (databaseName) { 130 | const requestedDatabase = databases.find((db) => db.name === databaseName); 131 | if (requestedDatabase) { 132 | return requestedDatabase; 133 | } 134 | } 135 | 136 | const defaultDatabase = databases.find( 137 | (db) => db.name === NEON_DEFAULT_DATABASE_NAME, 138 | ); 139 | return defaultDatabase || databases[0]; 140 | } 141 | 142 | /** 143 | * Resolves the organization ID for API calls that require org_id parameter. 144 | * 145 | * For new users (those without billing_account), this function fetches user's organizations and auto-selects only organization managed by console. If there are multiple organizations managed by console, it throws an error asking user to specify org_id. 146 | * 147 | * For existing users (with billing_account), returns undefined to use default behavior. 148 | * 149 | * @param params - The parameters object that may contain org_id 150 | * @param neonClient - The Neon API client 151 | * @returns The organization to use, or undefined for default behavior 152 | */ 153 | export async function getOrgIdForNewUsers( 154 | params: { org_id?: string }, 155 | neonClient: Api, 156 | ): Promise { 157 | if (params.org_id) { 158 | const { data } = await neonClient.getOrganization(params.org_id); 159 | return data; 160 | } 161 | 162 | const { data: user } = await neonClient.getCurrentUserInfo(); 163 | if (user.billing_account) { 164 | return undefined; 165 | } 166 | 167 | const { data: response } = await neonClient.getCurrentUserOrganizations(); 168 | const organizations = response.organizations || []; 169 | const consoleOrganizations = organizations.filter( 170 | (org) => org.managed_by === 'console', 171 | ); 172 | 173 | if (consoleOrganizations.length === 0) { 174 | throw new Error('No organizations found for this user'); 175 | } 176 | 177 | if (consoleOrganizations.length === 1) { 178 | return consoleOrganizations[0]; 179 | } else { 180 | const orgList = consoleOrganizations 181 | .map((org) => `- ${org.name} (ID: ${org.id})`) 182 | .join('\n'); 183 | throw new Error( 184 | `Multiple organizations found. Please specify the org_id parameter with one of the following organization IDs:\n${orgList}`, 185 | ); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import morgan from 'morgan'; 3 | import { Request, Response, NextFunction } from 'express'; 4 | 5 | const loggerFormat = winston.format.combine( 6 | winston.format.timestamp(), 7 | winston.format.simple(), 8 | winston.format.errors({ stack: true }), 9 | winston.format.align(), 10 | winston.format.colorize(), 11 | ); 12 | // Configure Winston logger 13 | export const logger = winston.createLogger({ 14 | level: 'info', 15 | format: loggerFormat, 16 | transports: [ 17 | new winston.transports.Console({ 18 | format: loggerFormat, 19 | }), 20 | ], 21 | }); 22 | 23 | // Configure Morgan for HTTP request logging 24 | export const morganConfig = morgan('combined', { 25 | stream: { 26 | write: (message: string) => logger.info(message.trim()), 27 | }, 28 | }); 29 | 30 | // Configure error handling middleware 31 | export const errorHandler = ( 32 | err: Error, 33 | req: Request, 34 | res: Response, 35 | next: NextFunction, 36 | ) => { 37 | logger.error('Error:', { error: err.message, stack: err.stack }); 38 | next(err); 39 | }; 40 | -------------------------------------------------------------------------------- /src/utils/polyfills.ts: -------------------------------------------------------------------------------- 1 | import nodeFetch, { 2 | Headers as NodeHeaders, 3 | Request as NodeRequest, 4 | Response as NodeResponse, 5 | } from 'node-fetch'; 6 | 7 | // Use different names to avoid conflicts 8 | declare global { 9 | function fetch( 10 | url: string | Request | URL, 11 | init?: RequestInit, 12 | ): Promise; 13 | } 14 | 15 | if (!global.fetch) { 16 | global.fetch = nodeFetch as any; 17 | global.Headers = NodeHeaders as any; 18 | global.Request = NodeRequest as any; 19 | global.Response = NodeResponse as any; 20 | } 21 | -------------------------------------------------------------------------------- /src/views/approval-dialog.pug: -------------------------------------------------------------------------------- 1 | - var clientName = client.client_name || 'A new MCP Client' 2 | - var logo = client.logo || client.logo_url || 'https://placehold.co/100x100/EEE/31343C?font=montserrat&text=MCP Client' 3 | - var website = client.client_uri || client.website 4 | - var redirectUris = client.redirect_uris 5 | - var serverName = 'Neon MCP Server' 6 | 7 | html(lang='en') 8 | head 9 | meta(charset='utf-8') 10 | meta(name='viewport', content='width=device-width, initial-scale=1') 11 | style 12 | include styles.css 13 | title #{clientName} | Authorization Request 14 | body 15 | div(class='container') 16 | div(class='precard') 17 | a(class="header", href='/', target='_blank') 18 | img(src='/logo.png', alt="Neon MCP", class="logo") 19 | div(class="card") 20 | h2(class="alert") 21 | strong MCP Client Authorization Request 22 | div(class="client-info") 23 | div(class='client-detail') 24 | div(class='detail-label') Name: 25 | div(class='detail-value') #{clientName} 26 | if website 27 | div(class='client-detail') 28 | div(class='detail-label') Website: 29 | div(class='detail-value small') 30 | a(href=website, target='_blank' rel='noopener noreferrer') #{website} 31 | if redirectUris 32 | div(class='client-detail') 33 | div(class='detail-label') Redirect URIs: 34 | div(class='detail-value small') 35 | each uri in redirectUris 36 | div #{uri} 37 | p(class="description") This MCP client is requesting to be authorized 38 | | on #{serverName}. If you approve, you will be redirected to complete the authentication. 39 | 40 | form(method='POST', action='/authorize') 41 | input(type='hidden', name='state', value=state) 42 | 43 | div(class='actions') 44 | button(type='button', class='button button-secondary' onclick='window.history.back()') Cancel 45 | button(type='submit', class='button button-primary') Approve 46 | -------------------------------------------------------------------------------- /src/views/styles.css: -------------------------------------------------------------------------------- 1 | /* Modern, responsive styling with system fonts */ 2 | :root { 3 | --primary-color: #0070f3; 4 | --error-color: #f44336; 5 | --border-color: #e5e7eb; 6 | --text-color: #dedede; 7 | --text-color-secondary: #949494; 8 | --background-color: #1c1c1c; 9 | --border-color: #2a2929; 10 | --card-shadow: 0 0px 12px 0px rgb(0 230 153 / 0.3); 11 | --link-color: rgb(0 230 153 / 1); 12 | } 13 | 14 | body { 15 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 16 | Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 17 | line-height: 1.6; 18 | color: var(--text-color); 19 | background-color: var(--background-color); 20 | margin: 0; 21 | padding: 0; 22 | } 23 | 24 | .container { 25 | max-width: 600px; 26 | 27 | margin: 2rem auto; 28 | padding: 1rem; 29 | } 30 | 31 | .precard { 32 | padding: 2rem; 33 | text-align: center; 34 | } 35 | 36 | .card { 37 | background-color: #0a0c09e6; 38 | border-radius: 8px; 39 | box-shadow: var(--card-shadow); 40 | padding: 2rem 2rem 0.5rem; 41 | } 42 | 43 | .header { 44 | display: flex; 45 | align-items: center; 46 | justify-content: center; 47 | margin-bottom: 1.5rem; 48 | color: var(--text-color); 49 | text-decoration: none; 50 | } 51 | 52 | .logo { 53 | width: 48px; 54 | height: 48px; 55 | margin-right: 1rem; 56 | border-radius: 8px; 57 | object-fit: contain; 58 | } 59 | 60 | .title { 61 | margin: 0; 62 | font-size: 1.3rem; 63 | font-weight: 400; 64 | } 65 | 66 | .alert { 67 | margin: 0; 68 | font-size: 1.5rem; 69 | font-weight: 400; 70 | margin: 1rem 0; 71 | text-align: center; 72 | } 73 | 74 | .description { 75 | color: var(--text-color-secondary); 76 | } 77 | 78 | .client-info { 79 | border: 1px solid var(--border-color); 80 | border-radius: 6px; 81 | padding: 1rem 1rem 0.5rem; 82 | margin-bottom: 1.5rem; 83 | } 84 | 85 | .client-name { 86 | font-weight: 600; 87 | font-size: 1.2rem; 88 | margin: 0 0 0.5rem 0; 89 | } 90 | 91 | .client-detail { 92 | display: flex; 93 | margin-bottom: 0.5rem; 94 | align-items: baseline; 95 | } 96 | 97 | .detail-label { 98 | font-weight: 500; 99 | min-width: 120px; 100 | } 101 | 102 | .detail-value { 103 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 104 | 'Courier New', monospace; 105 | word-break: break-all; 106 | } 107 | 108 | .detail-value a { 109 | color: inherit; 110 | text-decoration: underline; 111 | } 112 | 113 | .detail-value.small { 114 | font-size: 0.8em; 115 | } 116 | 117 | .external-link-icon { 118 | font-size: 0.75em; 119 | margin-left: 0.25rem; 120 | vertical-align: super; 121 | } 122 | 123 | .actions { 124 | display: flex; 125 | justify-content: flex-end; 126 | gap: 1rem; 127 | margin-top: 2rem; 128 | } 129 | 130 | .button { 131 | padding: 0.65rem 1rem; 132 | border-radius: 6px; 133 | font-weight: 500; 134 | cursor: pointer; 135 | border: none; 136 | font-size: 1rem; 137 | } 138 | 139 | .button-primary { 140 | background-color: rgb(0 229 153 / 1); 141 | color: rgb(26 26 26 / 1); 142 | } 143 | 144 | .button-secondary { 145 | background-color: transparent; 146 | border: 1px solid rgb(73 75 80 / 1); 147 | color: var(--text-color); 148 | } 149 | 150 | /* Responsive adjustments */ 151 | @media (max-width: 640px) { 152 | .container { 153 | margin: 1rem auto; 154 | padding: 0.5rem; 155 | } 156 | 157 | .card { 158 | padding: 1.5rem; 159 | } 160 | 161 | .client-detail { 162 | flex-direction: column; 163 | } 164 | 165 | .detail-label { 166 | min-width: unset; 167 | margin-bottom: 0.25rem; 168 | } 169 | 170 | .actions { 171 | flex-direction: column; 172 | } 173 | 174 | .button { 175 | width: 100%; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "outDir": "./dist", 12 | "typeRoots": ["./node_modules/@types", "./src/types"] 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "src/tools-evaluations/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/tools-evaluations/**/*"] 4 | } 5 | --------------------------------------------------------------------------------