0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コードを1行も書かずに「カメラで撮影してGoogleレンズ風に解説するMCPサーバー」を作った(GitHub Copilot + Gemini API)

0
Posted at

はじめに

Windows 11のPCに付いているWebカメラを、GitHub Copilot(VSCode)のプロンプトから使い、撮影した写真をGoogleレンズのようにAIに解説させる仕組みを作りました。

この記事の特徴は、こちらの記事と同様に私はコードを1行も書いていないことです。コードはすべてAI(GeminiとGitHub Copilot)が作成しました。いわゆるバイブコーディングの記録として、経緯と失敗も含めて紹介します。

実行すると、こんな結果が返ってきます。

カメラで撮影して、Googleレンズのように撮影内容を解説して。

撮影してGeminiで解析しました。画像には、セブンプレミアムのブラックコーヒーと思われるペットボトルが手前に写り、中央には眼鏡をかけた短髪の人物、その後ろには木製の収納家具やリモコンが見えます。商品名やボトルの残量は画像からの推定です。

経緯

1. 最初はGeminiに相談した

最近流行りのMCPサーバーを実際使ってみようと思い、最初にGeminiに相談しました。私としては「既存のMCPサーバーを探して、それを設定する」ことを想定していました。

ところが、Geminiは既存のカメラ用MCPサーバーを紹介せず、「MCPサーバーを自作しましょう」と提案してきました。カメラ用として適切な既存サーバーが見つからなかったのかもしれません。他に選択肢もなかったので、この提案を採用しました。

2. Geminiのコードはそのままでは動かなかった

Geminiが出したコードをそのまま使うと、次の問題がありました。

  • MCP SDKの書き方が古い、または誤っていた
    • server.onRequest(...) というハンドラー登録が使われていましたが、現行のSDKでは server.setRequestHandler(...) を使います
  • APIキーの設定方法が合わなかった
    • 後述しますが、Geminiの案内ではVSCode経由で起動したMCPサーバーにキーが渡りませんでした
  • 画像がBMPとJPGの2つできる問題(後述)

そこで、GitHub Copilot(エージェントモード)にコードの修正を依頼しました。以降はCopilotが修正を続け、最終的に動作するコードになりました。

3. 役割分担

役割 担当
構成の提案、最初のコード Gemini
コードの修正、環境変数の設定方法の再案内 GitHub Copilot
動作確認、API キーの取得、環境変数の設定 私(人間)

実行環境

構成図

SystemFig.png

使ったもの

  • Windows 11(Webカメラ内蔵のPC)
  • VSCode
  • GitHub Copilot 無償版(Chat のエージェントモードを使用。MCPツールはエージェントモードから呼び出されます)
  • Node.js(事前にインストール済み)
  • Gemini API(APIキーが必要)
  • npmパッケージ
    • @modelcontextprotocol/sdk : MCPサーバーのSDK
    • node-webcam : Webカメラでの撮影
    • @google/genai : Gemini APIのクライアント
    • sharp : 画像変換(BMP→PNG)

MCPとは

MCP(Model Context Protocol)は、AIアプリ(ここではGitHub Copilot)に、外部のツールやデータを接続するための共通の仕組みです。今回は「カメラで撮影する」「撮影して解析する」という2つのツールを、自作のMCPサーバーとして提供します。

Gemini APIキーの取得

Google AI Studio から無料で取得できます(無料枠の範囲であればクレジットカード登録は不要です)。

  1. ブラウザで Google AI Studio にアクセスし、Googleアカウントでログインする
  2. 初回は利用規約への同意画面が出るので、同意して進む
  3. 左側メニューなどにある「Get API key」を開く
  4. 「Create API key」をクリックし、プロジェクトを選択(または新規作成)する
  5. 生成されたキーをコピーする

APIキーはパスワードと同じです。GitHubの公開リポジトリにアップロードしたり、SNSやこの記事のようなスクリーンショットに写り込ませたりしないよう注意してください。

APIキーの設定(ここでつまずいた)

Geminiの案内(うまくいかなかった)

Geminiは、次のようにターミナルで設定する方法を案内しました。

set GEMINI_API_KEY=あなたのAPIキー
$env:GEMINI_API_KEY="あなたのAPIキー"

この方法は、そのターミナルのウィンドウの中でだけ有効です。VSCodeをアイコンなどから起動した場合、この設定は引き継がれず、MCPサーバーからキーが見えませんでした。

GitHub Copilotに聞き直して解決した方法

GitHub Copilotに聞き直したところ、ユーザー環境変数として登録する方法を案内されました。

  1. スタートメニューで「環境変数を編集」を検索して開く
  2. 「環境変数(N)...」を選び、「ユーザー環境変数」の「新規(N)...」をクリックする
  3. 次のように入力する
    • 変数名:GEMINI_API_KEY
    • 変数値:Google AI Studioで発行したAPIキー
  4. 開いているVSCodeをすべて終了してから起動し直す(環境変数は、起動後のMCPサーバーにも引き継がれます)

「VSCodeを完全に終了して起動し直す」のが重要です。起動したまま環境変数を追加しても反映されません。

APIキーの扱い方(コードにキーを書かない)

今回の構成では、APIキーをコードにも設定ファイルにも一切書いていません。キーがあるのは、Windowsのユーザー環境変数だけです。

仕組み

server.js は、環境変数から実行時にキーを読み込みます。

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

.vscode/mcp.json にも env でキーを渡す記述はありません。流れは次のとおりです。

Windowsのユーザー環境変数 (GEMINI_API_KEY)
   ↓ VSCode起動時に引き継ぐ
VSCode
   ↓ MCPサーバーとして node server.js を起動(環境変数も引き継ぐ)
server.js が process.env.GEMINI_API_KEY を読む

こうしておくと、server.js や mcp.json をGitHubに公開したり、記事に貼ったりしても、キーは漏れません。

未設定のときの動作

キーが設定されていない場合、take_and_analyze は撮影後に次のエラーを返します。

GEMINI_API_KEY 環境変数が設定されていません。

take_snapshot(撮影のみ)はキーなしでも動きます。

キーが設定されているかの確認方法

キーの値を画面に出さずに、設定の有無だけを確認する方法です。VSCodeを起動し直したあと、VSCodeのターミナル(PowerShell)で実行します。

if ($env:GEMINI_API_KEY) { "設定あり" } else { "未設定" }

echo $env:GEMINI_API_KEY のように値を表示すると、スクリーンショットや画面共有で写り込む恐れがあるので避けました。

運用上の注意

  • コードや mcp.json にキーを直接書かない。 公開リポジトリへのアップロードで漏れる一番の原因です
  • .env ファイルを使う場合は、.gitignore に追加する(今回は使っていません)
  • 記事やSNSのスクリーンショットにキーを写さない。環境変数の入力画面を載せるときは、変数値の欄を隠してください
  • 漏れた可能性があるときは、Google AI Studioでキーを削除し、新しく発行し直す。古いキーは無効になります。今回も、環境変数の設定をやり直すときに新しく発行したキーを使いました
  • 利用上限や料金は変わることがあるため、Google AI Studioの画面で最新の内容を確認する

参考:mcp.json で入力プロンプトとして渡す方法(今回は未採用)

VSCodeのMCP設定には、起動時にキーを入力させる inputs という仕組みもあります。環境変数を使いたくない場合の選択肢です。

{
  "inputs": [
    {
      "type": "promptString",
      "id": "gemini-api-key",
      "description": "Gemini API Key",
      "password": true
    }
  ],
  "servers": {
    "camera-server": {
      "type": "stdio",
      "command": "node",
      "args": ["${workspaceFolder}/server.js"],
      "env": {
        "GEMINI_API_KEY": "${input:gemini-api-key}"
      }
    }
  }
}

この方法だと、キーはファイルに保存されず、入力欄(伏せ字)に入れる形になります。ただし、サーバーを起動するたびに入力が必要になる場合があるので、手間と安全性のバランスで選んでください。

完成したコード

ここからが、AIが作成したコードです(私は一切触っていません)。

プロジェクト構成

mcp-camera-server/
├── .vscode/
│   └── mcp.json
├── captures/        (撮影画像が保存される場所)
├── package.json
└── server.js

package.json

{
  "name": "mcp-camera-server",
  "version": "2.0.0",
  "type": "module",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "@google/genai": "^2.31.0",
    "@modelcontextprotocol/sdk": "^1.32.1",
    "node-webcam": "^0.8.2",
    "sharp": "^0.34.5"
  }
}

npm install で依存パッケージをインストールします。

.vscode/mcp.json

{
  "servers": {
    "camera-server": {
      "type": "stdio",
      "command": "node",
      "args": [
        "${workspaceFolder}/server.js"
      ]
    }
  }
}

server.js

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import NodeWebcam from "node-webcam";
import fs from "fs";
import path from "path";
import { GoogleGenAI } from "@google/genai";
import sharp from "sharp";

// 保存先フォルダの作成
const outputDir = path.join(process.cwd(), "captures");
if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir);
}

// カメラの設定
const Webcam = NodeWebcam.create({
    width: 1280,
    height: 720,
    quality: 100,
    output: process.platform === "win32" ? "bmp" : "jpeg",
    callbackReturn: "location",
    verbose: false
});

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

async function getPngBuffer(imagePath) {
    const imageBuffer = fs.readFileSync(imagePath);

    if (imageBuffer.toString("ascii", 0, 2) !== "BM") {
        return sharp(imageBuffer).png().toBuffer();
    }

    const pixelOffset = imageBuffer.readUInt32LE(10);
    const headerSize = imageBuffer.readUInt32LE(14);
    const width = imageBuffer.readInt32LE(18);
    const signedHeight = imageBuffer.readInt32LE(22);
    const bitsPerPixel = imageBuffer.readUInt16LE(28);
    const compression = imageBuffer.readUInt32LE(30);

    if (headerSize < 40 || width <= 0 || signedHeight === 0 || bitsPerPixel !== 24 || compression !== 0) {
        throw new Error("撮影画像のBMP形式に対応していません。");
    }

    const height = Math.abs(signedHeight);
    const rowStride = Math.ceil((width * 3) / 4) * 4;
    if (pixelOffset + rowStride * height > imageBuffer.length) {
        throw new Error("撮影画像のBMPデータが不完全です。");
    }

    const pixels = Buffer.alloc(width * height * 3);
    for (let y = 0; y < height; y++) {
        const sourceY = signedHeight > 0 ? height - 1 - y : y;
        for (let x = 0; x < width; x++) {
            const sourceOffset = pixelOffset + sourceY * rowStride + x * 3;
            const targetOffset = (y * width + x) * 3;
            pixels[targetOffset] = imageBuffer[sourceOffset + 2];
            pixels[targetOffset + 1] = imageBuffer[sourceOffset + 1];
            pixels[targetOffset + 2] = imageBuffer[sourceOffset];
        }
    }

    return sharp(pixels, { raw: { width, height, channels: 3 } }).png().toBuffer();
}

// MCPサーバーの初期化
const server = new Server(
    {
        name: "camera-mcp-server",
        version: "2.0.0",
    },
    {
        capabilities: {
            tools: {},
        },
    }
);

// 利用可能なツールの定義
server.setRequestHandler(ListToolsRequestSchema, async () => {
    return {
        tools: [
            {
                name: "take_snapshot",
                description: "PCのWebカメラを使って静止画を撮影し、保存した画像のファイルパスを返します。",
                inputSchema: {
                    type: "object",
                    properties: {},
                },
            },
            {
                name: "take_and_analyze",
                description: "PCのWebカメラで写真を撮影し、GoogleレンズのようにAI(Gemini)に写っているものを解析・解説させます。",
                inputSchema: {
                    type: "object",
                    properties: {},
                },
            },
        ],
    };
});

// ツールが呼び出されたときの処理
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    if (request.params.name === "take_snapshot" || request.params.name === "take_and_analyze") {
        return new Promise((resolve, reject) => {
            const fileName = `snapshot_${Date.now()}`;
            const baseFullPath = path.join(outputDir, fileName);
            Webcam.capture(baseFullPath, async (err, data) => {
                if (err) {
                    reject(new Error(`カメラの撮影に失敗しました: ${err.message}`));
                    return;
                }

                // dataには拡張子を含む撮影画像のパスが返ります。
                const imagePath = data;

                if (request.params.name === "take_snapshot") {
                    resolve({
                        content: [
                            {
                                type: "text",
                                text: `カメラの撮影に成功しました。保存先: ${imagePath}`,
                            },
                        ],
                    });
                    return;
                }

                try {
                    if (!process.env.GEMINI_API_KEY) {
                        throw new Error("GEMINI_API_KEY 環境変数が設定されていません。");
                    }
                    const pngBuffer = await getPngBuffer(imagePath);

                    const response = await ai.models.generateContent({
                        model: "gemini-3.8-flash",
                        contents: [
                            {
                                inlineData: {
                                    data: pngBuffer.toString("base64"),
                                    mimeType: "image/png",
                                },
                            },
                            "この画像に写っているものについて、Googleレンズのように詳細に解説やコメントをしてください。日本語で分かりやすくお願いします。",
                        ],
                    });

                    const analysisText = response.text || "解析結果を取得できませんでした。";

                    resolve({
                        content: [
                            {
                                type: "text",
                                text: `【撮影・AI解析完了】\n保存先: ${imagePath}\n\n■ AIからのコメント:\n${analysisText}`,
                            },
                        ],
                    });
                } catch (aiError) {
                    reject(new Error(`画像のAI解析に失敗しました: ${aiError.message}`));
                }
            });
        });
    }

    throw new Error(`未知のツールです: ${request.params.name}`);
});

// 標準入出力でサーバーを起動
async function main() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
}

main().catch((error) => {
    console.error("サーバーエラー:", error);
    process.exit(1);
});

コードのポイント(AIの出力を読んだ範囲での解説)

  • take_snapshot は撮影のみ、take_and_analyze は撮影してGeminiに解説させる、という2つのツールを公開しています
  • Windowsでは node-webcam がBMPで保存します。最初のGemini版では「BMPとJPGの2ファイルが残る」問題があり、削除処理を足す案が出ましたが、最終的にCopilotは BMPで撮影し、sharp でPNGに変換してGeminiへ送る方式に変えました。このため captures フォルダには .bmp が保存されます
  • GEMINI_API_KEY が未設定の場合は、分かりやすいエラーメッセージを返します
  • server.setRequestHandler(...) が、現行のMCP SDKでのハンドラー登録方法です(Geminiの最初のコードの onRequest は誤りでした)

モデル名の gemini-3.8-flash は、Copilotが書いたものがそのまま動作しています。将来モデルが整理されて動かなくなった場合は、まずこの部分を、その時点で利用可能なモデル名に直してください。

使い方

  1. VSCodeでプロジェクトフォルダを開く(.vscode/mcp.json が読み込まれる)
  2. GitHub Copilot Chatを開き、エージェントモードにする
  3. 次のように話しかける

    カメラで撮影して、Googleレンズのように撮影内容を解説して。

  4. 初回は「camera-server からツールを許可しますか?」と確認が出る。take_and_analyze の実行を許可するため「このセッションで許可する」を選ぶ

カメラが一瞬動き、captures フォルダに画像が保存され、Geminiによる解説がチャットに表示されます。

感想と注意点

バイブコーディングで感じたこと

  • 私はコードを1行も触っていません。やったのは、AIへの依頼、エラーや動作結果の報告、APIキーの取得と環境変数の設定、動作確認だけです
  • 最初のAIのコードがそのまま動くとは限りません。今回も、SDKの書き方とAPIキーの渡し方でつまずきました
  • そういうときは、別のAIに聞き直す、または実行環境に近いAI(今回はVSCode内のCopilot)に直してもらうとうまくいきました。エラーを貼り付けて直してもらう、というやり取りだけで解決できます
  • 一方で、AIの出力を鵜呑みにせず、動作結果(写真と解説が合っているか)を自分の目で確認することは大切だと思います

セキュリティ・プライバシー面の注意

  • 撮影した画像は Gemini APIに送信されます。個人情報、機密情報が写らないように注意してください
  • APIキーは公開しない、リポジトリにコミットしない。万一貼り付けてしまった場合は再発行してください
  • MCPツールの実行は、毎回(または「このセッションで」)許可が必要です。意図しないカメラ起動を防ぐ仕組みなので、許可の内容を確認してから選んでください

今後やってみたいこと

  • ツールに質問の引数を追加し、「この商品の成分は?」「書かれている英語を翻訳して」など、毎回の問いかけを指定できるようにする
  • 撮影画像をMCPの画像コンテンツとして返し、Copilot自身にも見せる
  • 撮影画像の保存形式をJPEGにして、容量を抑える
  • エラー時は例外ではなく、MCPの isError 付きの結果として返す

まとめ

  • カメラ用の既存MCPサーバーを探すつもりが、Geminiの提案で自作することになった
  • Geminiのコードはそのままでは動かず、GitHub Copilotに修正してもらって完成した
  • APIキーは、ターミナルのコマンドではなく Windowsのユーザー環境変数に登録し、VSCodeを再起動する必要があった
  • コードは一切触らず、AIとの対話だけでGoogleレンズ風のカメラ解説ツールが動いた

同じような環境の方の参考になれば幸いです。

その他の記事は BB研究所 で公開しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?