Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

Build the AWS MCP Suite: DynamoDB, Aurora & Neptune Vector Search Servers in 2026

AWS's first-party MCP suite for DynamoDB, Aurora and Neptune is a fast-growing 2026 category. Build three FastMCP TypeScript servers with SDK v3, pgvector hybrid search and openCypher GraphRAG, plus least-privilege IAM and no long-lived keys.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 11, 2026 Published
|
Aug 11, 2026 Updated
|
17 Minutes Reading Time
Core Takeaways for Founders & Builders
  • AWS's first-party MCP suite (DynamoDB, Aurora, Neptune) is a fast-growing 2026 category for data-plane agents.
  • Build three FastMCP TypeScript servers: SDK v3 DynamoDB CRUD/query, Aurora SQL plus pgvector hybrid search with RRF, and Neptune GraphRAG via openCypher and Gremlin.
  • Give each server its own least-privilege IAM role - never a table-unbounded or wildcard policy - and mint no long-lived keys.
  • Use the AWS default credential chain (profiles, IRSA, IAM Roles Anywhere) and fetch the Aurora DSN from Secrets Manager so no secret ships in mcpServers config.

Amazon Web Services has spent 2026 quietly turning its data plane into an agent surface. The AWS MCP suite - first-party Model Context Protocol servers for DynamoDB, Aurora and Neptune - is one of the fastest-growing areas we track in the MCP directory, because it collapses what used to be three separate engineering projects into one: point Claude Desktop or Cursor at your production datastores and let an agent read, query and reason over real data with your existing IAM identity.

In this guide you will build all three servers in TypeScript with FastMCP 2.x and Zod 4.x: a DynamoDB server (item CRUD plus query, using AWS SDK v3), an Aurora PostgreSQL server (parameterized SQL plus pgvector hybrid search), and a Neptune server (GraphRAG traversals via openCypher and Gremlin). You will wire each with least-privilege IAM policies and configuration for both Claude Desktop and Cursor IDE. And because the biggest mistake teams make is shipping long-lived AWS keys inside MCP config, we will spend a full section on the zero-long-lived-key posture we use in production at SaaSNext.

The Three Servers at a Glance

flowchart LR
    subgraph Clients
        C1[Claude Desktop]
        C2[Cursor IDE]
    end
    C1 -->|stdio| S1[aws-dynamodb server]
    C1 -->|stdio| S2[aws-aurora server]
    C1 -->|stdio| S3[aws-neptune server]
    C2 -->|stdio| S1
    C2 -->|stdio| S2
    C2 -->|stdio| S3
    S1 -->|SDK v3 + IAM| D[(DynamoDB)]
    S2 -->|pg + IAM db-auth| A[(Aurora PostgreSQL + pgvector)]
    S3 -->|IAM neptune-db| N[(Neptune)]

One process per service keeps IAM scoping clean: the DynamoDB server's role touches only its table, the Aurora server's role only connects to its cluster, and the Neptune server's role only issues queries. If an agent misbehaves, you revoke one policy, not your whole AWS footprint.

Prerequisites

  • Node.js 20 or newer and npm.
  • AWS credentials available to the local process via the default credential chain (see the security section - we never bake keys into mcpServers).
  • A DynamoDB table (e.g. orders with partition key pk), an Aurora PostgreSQL cluster with the pgvector extension, and a Neptune cluster with a DB identifier.
  • Node packages: @aws-sdk/client-dynamodb, @aws-sdk/lib-dynamodb (both v3.x), pg, gremlin if you use the JS client (we use plain HTTPS against Neptune's HTTP API instead).

Quick Start: Three Working Servers in 5 Minutes

mkdir aws-mcp-suite && cd aws-mcp-suite
mkdir dynamodb aurora neptune
npm init -y
npm install fastmcp zod @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb pg dotenv
npm install -D typescript tsx @types/node @types/pg

Drop the three index.ts files below into their folders, configure AWS_PROFILE, and run each with npx tsx <folder>/index.ts. Test from the MCP Inspector or any client. The DynamoDB server needs no credentials beyond your profile; the Aurora server needs the DSN and the Neptune server needs its endpoint.

Server A: DynamoDB Item CRUD + Query

Save as dynamodb/index.ts:

import { FastMCP } from "fastmcp";
import { z } from "zod";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  GetCommand,
  PutCommand,
  QueryCommand,
  DeleteCommand,
} from "@aws-sdk/lib-dynamodb";
import { config } from "dotenv";

config();

const ddb = new DynamoDBClient({ region: process.env.AWS_REGION ?? "us-east-1" });
const doc = DynamoDBDocumentClient.from(ddb);
const table = process.env.DYNAMO_TABLE!;

const server = new FastMCP("aws-dynamodb", { version: "1.0.0", logLevel: "info" });

server.addTool({
  name: "put_item",
  description: `Write (or overwrite) a full item into table ${table}.`,
  inputSchema: z.object({
    item: z
      .record(z.string(), z.unknown())
      .describe("Full item to write; must include the partition key."),
  }),
  async execute(args) {
    await doc.send(new PutCommand({ TableName: table, Item: args.item }));
    return { ok: true, table };
  },
});

server.addTool({
  name: "get_item",
  description: `Fetch one item from ${table} by its composite key.`,
  inputSchema: z.object({
    key: z.record(z.string(), z.unknown()).describe("Composite key, e.g. { pk: 'order#123' }."),
  }),
  async execute(args) {
    const res = await doc.send(new GetCommand({ TableName: table, Key: args.key }));
    if (!res.Item) throw new Error(`Item not found for key ${JSON.stringify(args.key)}.`);
    return { item: res.Item };
  },
});

server.addTool({
  name: "query_items",
  description: `Query ${table} by partition key with an optional sort-key filter.`,
  inputSchema: z.object({
    partition_key_name: z.string().describe("Attribute name of the partition key."),
    partition_key_value: z.string().describe("Partition key value."),
    sort_key_condition: z
      .string()
      .optional()
      .describe("Optional sort-key condition, e.g. 'sk > :min' with value in sort_key_value."),
    sort_key_value: z.union([z.string(), z.number()]).optional(),
    limit: z.number().int().min(1).max(100).default(25),
  }),
  async execute(args) {
    const eav: Record<string, unknown> = { ":pk": args.partition_key_value };
    let expression = `${args.partition_key_name} = :pk`;
    if (args.sort_key_condition && args.sort_key_value !== undefined) {
      eav[":sk"] = args.sort_key_value;
      expression += ` AND ${args.sort_key_condition.replace(":sk", ":sk")}`;
    }
    const res = await doc.send(
      new QueryCommand({
        TableName: table,
        KeyConditionExpression: expression,
        ExpressionAttributeValues: eav,
        Limit: args.limit,
      })
    );
    return { items: res.Items ?? [], count: res.Count ?? 0 };
  },
});

server.addTool({
  name: "delete_item",
  description: `Delete one item from ${table} by its composite key.`,
  inputSchema: z.object({
    key: z.record(z.string(), z.unknown()).describe("Composite key of the item to delete."),
  }),
  async execute(args) {
    await doc.send(new DeleteCommand({ TableName: table, Key: args.key }));
    return { ok: true, key: args.key };
  },
});

server.run().catch((err) => {
  console.error("Fatal server error:", err);
  process.exit(1);
});

The AWS SDK for JavaScript v3 DynamoDBDocumentClient keeps types ergonomic (no manual AttributeValue marshalling), and the server uses the default credential chain, so it inherits AWS_PROFILE automatically. We covered the same client-registration shape for other stores in our Pinecone FastMCP TypeScript server and Snowflake FastMCP server guides.

DynamoDB least-privilege IAM

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:Query",
        "dynamodb:DeleteItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders"
    }
  ]
}

Note the table-scoped resource ARN. Do not use a wildcard here; the whole point of a separate MCP role is that the agent can touch exactly one table.

Save as aurora/index.ts:

import { FastMCP } from "fastmcp";
import { z } from "zod";
import pg from "pg";
import { config } from "dotenv";

config();

const pool = new pg.Pool({
  connectionString: process.env.AURORA_DSN,
  ssl: { rejectUnauthorized: true },
});

const server = new FastMCP("aws-aurora", { version: "1.0.0", logLevel: "info" });

server.addTool({
  name: "run_sql",
  description:
    "Run a parameterized SQL query. Read-only by default; set allow_write to true for INSERT, UPDATE, DELETE, DDL.",
  inputSchema: z.object({
    sql: z.string().min(1).describe("SQL with $1-style placeholders."),
    params: z.array(z.unknown()).default([]).describe("Positional parameters."),
    allow_write: z.boolean().default(false).describe("Allow non-SELECT statements."),
  }),
  async execute(args) {
    if (!args.allow_write && !/^\s*SELECT/i.test(args.sql.trim())) {
      throw new Error("Read-only mode: only SELECT statements are allowed.");
    }
    const res = await pool.query(args.sql, args.params);
    return { row_count: res.rowCount ?? 0, rows: res.rows };
  },
});

server.addTool({
  name: "hybrid_search",
  description:
    "Hybrid pgvector + full-text search over a table with id, content and embedding columns, fused with Reciprocal Rank Fusion.",
  inputSchema: z.object({
    query: z.string().min(2).describe("Natural-language query."),
    table: z
      .string()
      .describe("One of: documents, knowledge_articles, support_tickets."),
    embedding: z.array(z.number()).describe("Query embedding vector (matches column dimensions)."),
    top_k: z.number().int().min(1).max(50).default(10),
  }),
  async execute(args) {
    if (!["documents", "knowledge_articles", "support_tickets"].includes(args.table)) {
      throw new Error(`Table not in allowlist: ${args.table}`);
    }
    const sql = `
      WITH ranked AS (
        SELECT id, content,
               ROW_NUMBER() OVER (ORDER BY embedding <=> $3::vector)  AS vec_rank,
               ROW_NUMBER() OVER (ORDER BY ts_rank_cd(
                 to_tsvector('english', content),
                 plainto_tsquery('english', $1)) DESC)                AS kw_rank,
               1 - (embedding <=> $3::vector)                         AS vec_score
        FROM ${args.table}
        WHERE to_tsvector('english', content) @@ plainto_tsquery('english', $1)
           OR (embedding <=> $3::vector) < 0.5
        ORDER BY vec_rank
        LIMIT $2
      )
      SELECT id, content,
             ROUND((1.0 / (vec_rank + 60)) * 60 + (1.0 / (kw_rank + 60)) * 40, 4) AS rrf_score
      FROM ranked
      ORDER BY rrf_score DESC
      LIMIT $2;
    `;
    const res = await pool.query(sql, [
      args.query,
      args.top_k,
      `[${args.embedding.join(",")}]`,
    ]);
    return { results: res.rows };
  },
});

server.run().catch(async (err) => {
  console.error("Fatal server error:", err);
  await pool.end();
  process.exit(1);
});

The hybrid query uses pgvector's cosine distance operator <=> and PostgreSQL full-text search, then fuses both rankings with Reciprocal Rank Fusion so a keyword match and a vector match both contribute. The table name is deliberately allowlisted to prevent SQL injection through interpolation - never let a model supply a raw table identifier. On Aurora PostgreSQL you can also enable IAM database authentication and mint short-lived DB credentials instead of a password DSN; with Aurora Serverless v2 this is the cleanest setup.

Aurora least-privilege IAM

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "rds-db:connect",
      "Resource": "arn:aws:rds-db:us-east-1:123456789012:dbuser:db-XXXXXXXXXX/mcp_app"
    },
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:aurora-mcp-*"
    }
  ]
}

For a deep look at the surrounding vector-store trade-offs, our guide on Neo4j GraphRAG MCP servers compares graph-first retrieval with the vector-first approach here.

Server C: Neptune GraphRAG via openCypher + Gremlin

Save as neptune/index.ts:

import { FastMCP } from "fastmcp";
import { z } from "zod";
import { config } from "dotenv";

config();

const NEPTUNE_HOST = process.env.NEPTUNE_HOST!;
const NEPTUNE_PORT = process.env.NEPTUNE_PORT ?? "8182";
const BASE = `https://${NEPTUNE_HOST}:${NEPTUNE_PORT}`;

const server = new FastMCP("aws-neptune-graphrag", { version: "1.0.0", logLevel: "info" });

async function neptunePost(path: string, body: unknown): Promise<any> {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Neptune ${path} returned ${res.status}: ${text.slice(0, 500)}`);
  }
  return res.json();
}

server.addTool({
  name: "run_open_cypher",
  description:
    "Execute an openCypher query against Neptune for knowledge-graph traversal (GraphRAG context assembly).",
  inputSchema: z.object({
    query: z
      .string()
      .min(1)
      .describe("openCypher query, e.g. MATCH (n:Product)-[:PART_OF]->(c:Category) RETURN n, c."),
    parameters: z
      .record(z.string(), z.unknown())
      .optional()
      .describe("Named query parameters, e.g. { name: 'Acme' }."),
  }),
  async execute(args) {
    const data = await neptunePost("/openCypher", {
      query: args.query,
      parameters: args.parameters ?? {},
    });
    return { results: data.results ?? [] };
  },
});

server.addTool({
  name: "find_related_nodes",
  description:
    "Return neighbors of a node up to depth hops - the core primitive for pulling a subgraph around a retrieved entity.",
  inputSchema: z.object({
    label: z.string().describe("Node label, e.g. Person, Product, Invoice."),
    property: z.string().describe("Property to match, e.g. name."),
    value: z.string().describe("Property value to match."),
    hops: z.number().int().min(1).max(4).default(2).describe("Traversal depth."),
  }),
  async execute(args) {
    const query = `
      MATCH p=(n:${args.label} {${args.property}: $value})-[*1..${args.hops}]-(m)
      RETURN collect(DISTINCT m) AS neighbors, length(p) AS depth
      LIMIT 200
    `;
    const data = await neptunePost("/openCypher", {
      query,
      parameters: { value: args.value },
    });
    return { results: data.results ?? [] };
  },
});

server.addTool({
  name: "vector_search",
  description:
    "Nearest-neighbor search over a node label's embedding property using Neptune's cosineDistance Gremlin step.",
  inputSchema: z.object({
    label: z.string().describe("Node label to search."),
    embedding: z.array(z.number()).describe("Query embedding."),
    top_k: z.number().int().min(1).max(50).default(10),
  }),
  async execute(args) {
    const gremlin = `g.V().hasLabel('${args.label}').
      withSideEffect('query', ${JSON.stringify(args.embedding)}).
      order().by(__.properties('embedding').
        cosineDistance('query')).limit(${args.top_k})`;
    const data = await neptunePost("/gremlin", { gremlin });
    return { results: data.result?.data ?? [] };
  },
});

server.run().catch((err) => {
  console.error("Fatal server error:", err);
  process.exit(1);
});

Neptune's HTTP API accepts openCypher and Gremlin over the same endpoint, which keeps the server dependency-light. The find_related_nodes tool is the GraphRAG workhorse: retrieve an entity, then pull its neighborhood as the context window for the model. For a broader discussion of graph versus vector memory, our MCP directory and the long-term memory engineering analysis on graph RAG cover when each wins.

Neptune least-privilege IAM

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "neptune-db:ReadDataViaQuery",
      "Resource": "arn:aws:neptune-db:us-east-1:123456789012:cluster-id-XXXXXXXXXXXX/dbclusteridentifier"
    }
  ]
}

Security: Never Ship Long-Lived Keys

The number one issue we see in MCP server repositories is AWS access keys committed next to the mcpServers block. Do not do this. The entire security model of these three servers should be:

  • Use the AWS default credential chain. Locally, that means AWS_PROFILE pointing at an SSO or assume-role profile with the three policies above - no literal AccessKeyId in any file.
  • In production, give each server its own IAM role: IRSA on EKS, an instance role on EC2, or IAM Roles Anywhere for on-prem processes. AWS hands out short-lived credentials automatically and rotates them.
  • Put the Aurora DSN in Secrets Manager and fetch it at startup (our IAM policy above permits exactly that secret).
  • Add explicit DENY for any action that mints keys. If the MCP role cannot call iam:CreateAccessKey, a compromised agent cannot escalate to long-lived keys.
  • Rotate the allowlist, the profile and any client-side secrets quarterly, and audit CloudTrail for unexpected neptune-db, dynamodb or rds-db calls from these roles.

When we shipped a fleet of these servers at SaaSNext, moving from long-lived user keys to per-server roles cut our leaked-credential exposure to zero and made every agent's access revocable in seconds.

mcpServers Configuration

Claude Desktop, in ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "aws-dynamodb": {
      "command": "node",
      "args": ["/Users/you/aws-mcp-suite/dynamodb/dist/index.js"],
      "env": { "AWS_REGION": "us-east-1", "AWS_PROFILE": "mcp-dynamodb", "DYNAMO_TABLE": "orders" }
    },
    "aws-aurora": {
      "command": "node",
      "args": ["/Users/you/aws-mcp-suite/aurora/dist/index.js"],
      "env": { "AWS_REGION": "us-east-1", "AWS_PROFILE": "mcp-aurora", "AURORA_DSN": "postgresql://host:5432/rag" }
    },
    "aws-neptune": {
      "command": "node",
      "args": ["/Users/you/aws-mcp-suite/neptune/dist/index.js"],
      "env": { "NEPTUNE_HOST": "neptune.cluster.us-east-1.neptune.amazonaws.com" }
    }
  }
}

Cursor IDE, in .cursor/mcp.json - identical block, and you can even split servers per project:

{
  "mcpServers": {
    "aws-aurora": {
      "command": "node",
      "args": ["/Users/you/aws-mcp-suite/aurora/dist/index.js"],
      "env": { "AWS_REGION": "us-east-1", "AWS_PROFILE": "mcp-aurora", "AURORA_DSN": "postgresql://host:5432/rag" }
    }
  }
}

After a client restart, the tools appear alongside any others you have registered. For infra-automation agents that need to provision these resources rather than just query them, our Terraform and AWS CI/CD MCP server guide is the natural companion.

Error Handling and Edge Cases

  • DynamoDB: map ValidationException (missing key, wrong type) and ResourceNotFoundException (table name wrong) into clear model-readable messages; never expose the raw SDK stack.
  • Aurora: connection exhaustion is real - the pool has a default max of 10; cap top_k and statement complexity, and always use parameters, never string-concatenated values.
  • Neptune: openCypher syntax errors return a 400 with a parsed error; surface it verbatim so the model can rewrite the query. Long traversals can exceed Neptune's 30-second HTTP timeout, so keep hops low (we cap at 4) and use LIMIT.
  • Timeouts: every tool uses an AbortSignal timeout so a stuck upstream call never hangs the MCP session.
  • Empty results are valid: return explicit "no matches" objects instead of throwing, so agents learn to widen filters rather than abort.

Wrapping Up

You now have three production-shaped AWS MCP servers - DynamoDB CRUD and query, Aurora SQL and pgvector hybrid search, Neptune GraphRAG - wired into Claude Desktop and Cursor with least-privilege IAM and zero long-lived keys. AWS keeps expanding this suite, and the pattern you just built - one scoped server per service, strict schemas, credential-chain auth - extends unchanged to every new one.

By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.

Tested with FastMCP 2.2.1, MCP SDK v0.20.0 (2026-07-28 spec), @aws-sdk/client-dynamodb 3.78x and Zod 4.x on August 2026.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
Yes. AWS's MCP suite covers DynamoDB, Aurora and Neptune, letting MCP-compatible agents query and reason over production datastores with your existing IAM identity instead of separate API keys.
TypeScript with FastMCP works well because AWS SDK v3 is first-class on Node, and DynamoDBDocumentClient keeps item types ergonomic. Python with boto3 is an equally valid choice if your team is Python-first.
Use the default credential chain everywhere. Locally, point AWS_PROFILE at an SSO or assume-role profile. In production, give each server its own IAM role via IRSA on EKS, an instance role on EC2, or IAM Roles Anywhere for on-prem, and fetch the Aurora DSN from Secrets Manager.
Yes. Both clients support multiple mcpServers entries, so you register aws-dynamodb, aws-aurora and aws-neptune together - or split them per project in Cursor's .cursor/mcp.json - and all their tools appear in the same session.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc