├── .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 | [](https://www.npmjs.com/package/@neondatabase/mcp-server-neon)
8 | [](https://www.npmjs.com/package/@neondatabase/mcp-server-neon)
9 | [](https://opensource.org/licenses/MIT)
10 | [](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 |
--------------------------------------------------------------------------------