Amazon Neptuneに対しGremlin MCPサーバーを利用して自然言語でクエリしてみた

Amazon Neptuneに対しGremlin MCPサーバーを利用して自然言語でクエリしてみた

2025.11.13

データ事業本部のueharaです。

今回は、Amazon Neptuneに対しGremlin MCPサーバーを利用して自然言語でクエリ実行をしてみたいと思います。

はじめに

AWSのグラフDBサービスにAmazon Neptuneがありますが、SQLと比較し慣れないGremlinに苦労している方は多いのではないでしょうか。

Amazon NeptuneのMCPサーバーとして、awslabsでも公開されていますが本検証ではそちらは利用しません

https://github.com/awslabs/mcp/tree/main/src/amazon-neptune-mcp-server

今回は純粋に、Apache Tinkerpop(Gremlinが利用される大元のフレームワーク)のリポジトリにあるGremlinのMCPサーバー実装を利用したいと思います。

https://github.com/apache/tinkerpop/tree/master/gremlin-mcp/src/main/javascript

構成について

MCPサーバーはローカルで動作させます。

Neptuneはプライベートサブネットで動作させ、AWS SSMのポートフォワーディング機能を用いて、ローカル端末からリモートホストへ安全にアクセスする形を取りたいと思います。

20251113_nep_01

※厳密には、Neptuneのサブネットグループには2つのプライベートサブネットを指定しますが、図の簡略化のため1つのみ記載しています。

なお、今回はMCPクライアントとしてはCursorを利用しますが、Calude Code等その他ツールでも問題ありません。

インフラの作成

以下のCloudFormationテンプレートを利用して、Amazon Neptune (Serverless)とポートフォワーディングのためのEC2を作成します。

なお、ここではVPCやサブネットは既に構築済みであり、SSMのVPCエンドポイントもVPCに設定済みであることを想定しています。

infra.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Neptune cluster and bastion ec2 instance

Parameters:
  VpcId:
    Type: AWS::EC2::VPC::Id
    Description: VPC ID where the Aurora instance will be launched

  PrivateSubnet01Id:
    Type: AWS::EC2::Subnet::Id
    Description: First private subnet ID where the Neptune cluster will be launched

  PrivateSubnet02Id:
    Type: AWS::EC2::Subnet::Id
    Description: Second private subnet ID where the Neptune cluster will be launched

  Ec2InstanceType:
    Type: String
    Default: t3.micro
    Description: EC2 instance type for bastion host

  Ec2ImageId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
    Description: AMI ID for bastion host (Amazon Linux 2023)

Resources:
  # Bastion EC2 Security Group
  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${AWS::StackName}-bastion-ec2-sg
      GroupDescription: Security group for bastion EC2 instance
      VpcId: !Ref VpcId
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0

  # Neptune Security Group
  NeptuneSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${AWS::StackName}-neptune-sg
      GroupDescription: Security group for Neptune cluster
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 8182
          ToPort: 8182
          SourceSecurityGroupId: !Ref EC2SecurityGroup
          Description: Allow Neptune traffic from bastion EC2
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0

  # Neptune DB Subnet Group
  NeptuneSubnetGroup:
    Type: AWS::Neptune::DBSubnetGroup
    Properties:
      DBSubnetGroupName: !Sub ${AWS::StackName}-neptune-subnet-group
      DBSubnetGroupDescription: Subnet group for Neptune cluster
      SubnetIds:
        - !Ref PrivateSubnet01Id
        - !Ref PrivateSubnet02Id

  # Neptune Serverless Cluster
  NeptuneCluster:
    Type: AWS::Neptune::DBCluster
    Properties:
      DBClusterIdentifier: !Sub ${AWS::StackName}-neptune-cluster
      EngineVersion: 1.4.5.1
      DBSubnetGroupName: !Ref NeptuneSubnetGroup
      VpcSecurityGroupIds:
        - !Ref NeptuneSecurityGroup
      Port: 8182
      ServerlessScalingConfiguration:
        MinCapacity: 1
        MaxCapacity: 16
      IamAuthEnabled: false

  # Neptune Serverless Instance
  NeptuneInstance:
    Type: AWS::Neptune::DBInstance
    Properties:
      DBInstanceIdentifier: !Sub ${AWS::StackName}-neptune-instance
      DBClusterIdentifier: !Ref NeptuneCluster
      DBInstanceClass: db.serverless

  # EC2 Key Pair
  EC2KeyPair:
    Type: AWS::EC2::KeyPair
    Properties:
      KeyName: !Sub ${AWS::StackName}-bastion-keypair

  # IAM Role for EC2 (SSM access)
  EC2IAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${AWS::StackName}-bastion-ec2-role
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  # EC2 Instance Profile
  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - !Ref EC2IAMRole
      InstanceProfileName: !Sub ${AWS::StackName}-bastion-ec2-instanceprofile

  # Bastion EC2 Instance
  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref Ec2InstanceType
      SubnetId: !Ref PrivateSubnet01Id
      ImageId: !Ref Ec2ImageId
      SecurityGroupIds:
        - !Ref EC2SecurityGroup
      IamInstanceProfile: !Ref EC2InstanceProfile
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            VolumeSize: 100
            VolumeType: gp3
      EbsOptimized: true
      SourceDestCheck: true
      KeyName: !Ref EC2KeyPair
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-bastion-ec2

Outputs:
  NeptuneClusterEndpoint:
    Description: Neptune cluster endpoint
    Value: !GetAtt NeptuneCluster.Endpoint
    Export:
      Name: !Sub ${AWS::StackName}-neptune-endpoint

  NeptuneClusterReadEndpoint:
    Description: Neptune cluster read endpoint
    Value: !GetAtt NeptuneCluster.ReadEndpoint
    Export:
      Name: !Sub ${AWS::StackName}-neptune-read-endpoint

  NeptuneClusterPort:
    Description: Neptune cluster port
    Value: !GetAtt NeptuneCluster.Port
    Export:
      Name: !Sub ${AWS::StackName}-neptune-port

  NeptuneSecurityGroupId:
    Description: Neptune security group ID
    Value: !Ref NeptuneSecurityGroup
    Export:
      Name: !Sub ${AWS::StackName}-neptune-sg-id

  BastionEC2InstanceId:
    Description: Bastion EC2 instance ID
    Value: !Ref EC2Instance
    Export:
      Name: !Sub ${AWS::StackName}-bastion-ec2-id

  BastionEC2SecurityGroupId:
    Description: Bastion EC2 security group ID
    Value: !Ref EC2SecurityGroup
    Export:
      Name: !Sub ${AWS::StackName}-bastion-ec2-sg-id

  BastionEC2KeyPairName:
    Description: Bastion EC2 key pair name
    Value: !Ref EC2KeyPair
    Export:
      Name: !Sub ${AWS::StackName}-bastion-keypair-name

上記CloudFormationテンプレートの適用が完了すると、以下のようにリソースが作成されるかと思います。

20251113_nep_02

Gremlin Consoleを利用した接続とデータ準備

インフラの作成が完了したら、Gremlin Consoleを利用してローカル端末からNeptuneにアクセスしてみます。

Gremlin Consoleのインストール

前置きとして、Gremlin Consoleには Java 8 または Java 11 が必要ですが、ここでは Java 11 の使用を前提とさせて頂きます。

まず、ApacheのWebサイトからGremlin Consoleをダウンロードします。

$ wget https://archive.apache.org/dist/tinkerpop/3.7.2/apache-tinkerpop-gremlin-console-3.7.2-bin.zip

※現在実行中のNeptuneのエンジンバージョンに対し、AWSの公式ドキュメントからサポートされているGremlin Consoleのバージョンを確認できます。

ダウンロードが完了したらzipファイルを解凍します。

$ unzip apache-tinkerpop-gremlin-console-3.7.2-bin.zip

設定ファイルの作成

次に、ディレクトリを解凍したディレクトリに変更します。

$ cd apache-tinkerpop-gremlin-console-3.7.2

接続設定は解凍されたディレクトリ配下にある conf に保存しますが、以下のような neptune-remote.yaml という名前のファイルを作成します。

neptune-remote.yaml
hosts: [localhost]
port: 8182
connectionPool: {
  enableSsl: true
}
serializer: { className: org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV1, config: { serializeResultToString: true } }

今回は冒頭の通りSSMのポートフォワーディングを利用するため、ホスト名は localhost としています。

Gremlin Consoleの起動

準備が完了したら、まずGremlin Consoleから接続するため、別のターミナルでリモートホストへのポートフォワーディングを行います。

別のターミナルで実行
$ aws ssm start-session \
  --profile <YOUR AWS PROFILE> \
  --target <YOUR EC2 INSTANCE ID> \
  --region ap-northeast-1 \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["<YOUR NEPTUNE CLUSTER HOST>"],"portNumber":["8182"], "localPortNumber":["8182"]}'

SSMのセッションを開始したら、元のターミナル(Gremlin Consoleのディレクトリを開いているターミナル)に戻り、Gremlin Consoleを起動します。

$ bin/gremlin.sh

Gremlin Consoleが起動したら、以下コマンドを実行しNeptuneに接続します。

:remote connect tinkerpop.server conf/neptune-remote.yaml

その後、以降のすべてのコマンドを自動的にリモートサーバーに送信するモードに切り替えます。

:remote console

ここまで完了すると、ターミナルは以下のようになっているかと思います。

20251113_nep_03

データのロード

今回作成するグラフは、以前のブログ記事と同様、最短経路問題を解くための以下のグラフを利用したいと思います。

20230804_neptune_01
引用元

上記グラフの定義は以下の通りです。

  • ノードはアルファベットに丸で、エッジはノードをつなぐ線で表現されている
  • エッジの近くの数字は、エッジの重み(ノード間の距離)を表す

これをロードするため、Gremlin Consoleディレクトリ配下に graph.groovy という名前の以下ファイルを作成します。

g.addV('node').property('id', 'a').as('a').
  addV('node').property('id', 'b').as('b').
  addV('node').property('id', 'c').as('c').
  addV('node').property('id', 'd').as('d').
  addV('node').property('id', 'e').as('e').
  addV('node').property('id', 'f').as('f').
  addV('node').property('id', 'g').as('g').
  addV('node').property('id', 'h').as('h').
  addE('edge').from('a').to('b').property('cost', 1).
  addE('edge').from('a').to('c').property('cost', 7).
  addE('edge').from('a').to('d').property('cost', 2).
  addE('edge').from('b').to('a').property('cost', 1).
  addE('edge').from('b').to('e').property('cost', 2).
  addE('edge').from('b').to('f').property('cost', 4).
  addE('edge').from('c').to('a').property('cost', 7).
  addE('edge').from('c').to('f').property('cost', 2).
  addE('edge').from('c').to('g').property('cost', 3).
  addE('edge').from('d').to('a').property('cost', 2).
  addE('edge').from('d').to('g').property('cost', 5).
  addE('edge').from('e').to('b').property('cost', 2).
  addE('edge').from('e').to('f').property('cost', 1).
  addE('edge').from('f').to('b').property('cost', 4).
  addE('edge').from('f').to('c').property('cost', 2).
  addE('edge').from('f').to('e').property('cost', 1).
  addE('edge').from('f').to('h').property('cost', 6).
  addE('edge').from('g').to('c').property('cost', 3).
  addE('edge').from('g').to('d').property('cost', 5).
  addE('edge').from('g').to('h').property('cost', 2).
  addE('edge').from('h').to('f').property('cost', 6).
  addE('edge').from('h').to('g').property('cost', 2).iterate()

ファイルが作成できたら、Gremlin Consoleからロードを行います。

:load graph.groovy

ロードが完了したら、念のため確認しておきます。

g.V().limit(5)

以下のようになっていれば完了です。

20251113_nep_04

Gremlin MCPサーバーの用意

次にGremlin MCPサーバーの準備を行います。

TinkerpopのリポジトリはMCPサーバーの実装以外にも様々なディレクトリがあるので、冒頭で示したURLだけダウンロードするよう以下手順でダウンロードを行います。

# 1. リポジトリをクローン(filter + sparse オプション付き)
git clone --filter=blob:none --sparse https://github.com/apache/tinkerpop.git

# 2. クローンしたディレクトリに移動
cd tinkerpop

# 3. sparse-checkout で欲しいフォルダを指定
git sparse-checkout set gremlin-mcp/src/main/javascript

上記実行が完了すると gremlin-mcp/src/main/javascript ディレクトリのみダウンロードができているかと思います。

これからビルドが必要なのですが、その前に1ファイルだけ書き換えを行います。

今回SSMのポートフォワーディングでlocalhostとして接続するため、そのままの状態だと証明書の検証でエラーが発生します。

対策はいくつかあるのですが、今回は検証のため一旦エラーが発生しても接続を拒否しない設定を入れたいと思います。(本番運用では推奨される設定ではないためご留意下さい。)

gremlin-mcp/src/main/javascript 配下の、 src/gremlin/connection.ts の一部を以下のように修正します。

src/gremlin/connection.ts
  const connection = yield* Effect.try({
    try: () =>
      new DriverRemoteConnection(url, {
        traversalSource,
        authenticator: Option.getOrUndefined(authenticator),
        rejectUnauthorized: false, // 追加
      }),
    catch: error => Errors.connection('Failed to create remote connection', { error }),
  });

  const g = AnonymousTraversalSource.traversal().withRemote(connection);
  const client = new Client(url, {
    traversalSource,
    authenticator: Option.getOrUndefined(authenticator),
    rejectUnauthorized: false, // 追加
  });

修正が完了したら、 gremlin-mcp/src/main/javascript 配下に移動しビルドを行います。

# 1. ディレクトリの移動
$ cd gremlin-mcp/src/main/javascript

# 2. 必要なファイルのインストール
$ npm install

# 3. ビルド
$ npm run build

これでGremlin MCPサーバーの準備は完了です。

Cursorから自然言語でのクエリ実行

MCPサーバーの用意ができたので、Cursorから自然言語でクエリを実行してみます。(SSMのポートフォワーディングが引き続き実行されていることを確認して下さい)

適当なディレクトリをCursorで開き、 .cursor/mcp.json ファイルとして以下を作成します。

.cusror/mcp.json
{
  "mcpServers": {
    "gremlin": {
      "command": "npx",
      "args": ["<YOUR PATH>/tinkerpop/gremlin-mcp/src/main/javascript/dist/server.js"],
      "env": {
        "GREMLIN_MCP_ENDPOINT": "localhost:8182",
        "GREMLIN_MCP_LOG_LEVEL": "info",
        "GREMLIN_MCP_USE_SSL": "true"
      }
    }
  }
}

<YOUR PATH> については前項の「Gremlin MCPサーバーの用意」で作業をしたご自身のパスを設定して下さい。

設定したらそのままCursorでMCPサーバーを起動し、以下のようになっていれば成功です。

20251113_nep_05

MCPサーバーの起動が確認できたので、Cursorのチャット欄から、以下メッセージを送信してみます。

グラフDBに接続して、ノードaからノードhまでの最短経路(エッジの総コストが最小)を知りたい。

グラフDBのスキーマも教えず結構雑な感じで依頼してみましたが、スキーマの確認から必要だと判断し適切に動作をしています。

20251113_nep_06

いくつかのクエリ実行の後、最終的に、ノードaからノードhまでの正しい最短経路を出力してくれました。

20251113_nep_07

最後に

今回は、Amazon Neptuneに対しGremlin MCPサーバーを利用して自然言語でクエリ実行をしてみました。

参考になりましたら幸いです。

参考文献

この記事をシェアする

FacebookHatena blogX

関連記事