はじめに
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 キーの取得、環境変数の設定 | 私(人間) |
実行環境
構成図
使ったもの
- 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 から無料で取得できます(無料枠の範囲であればクレジットカード登録は不要です)。
- ブラウザで Google AI Studio にアクセスし、Googleアカウントでログインする
- 初回は利用規約への同意画面が出るので、同意して進む
- 左側メニューなどにある「Get API key」を開く
- 「Create API key」をクリックし、プロジェクトを選択(または新規作成)する
- 生成されたキーをコピーする
APIキーはパスワードと同じです。GitHubの公開リポジトリにアップロードしたり、SNSやこの記事のようなスクリーンショットに写り込ませたりしないよう注意してください。
APIキーの設定(ここでつまずいた)
Geminiの案内(うまくいかなかった)
Geminiは、次のようにターミナルで設定する方法を案内しました。
set GEMINI_API_KEY=あなたのAPIキー
$env:GEMINI_API_KEY="あなたのAPIキー"
この方法は、そのターミナルのウィンドウの中でだけ有効です。VSCodeをアイコンなどから起動した場合、この設定は引き継がれず、MCPサーバーからキーが見えませんでした。
GitHub Copilotに聞き直して解決した方法
GitHub Copilotに聞き直したところ、ユーザー環境変数として登録する方法を案内されました。
- スタートメニューで「環境変数を編集」を検索して開く
- 「環境変数(N)...」を選び、「ユーザー環境変数」の「新規(N)...」をクリックする
- 次のように入力する
- 変数名:GEMINI_API_KEY
- 変数値:Google AI Studioで発行したAPIキー
- 開いている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が書いたものがそのまま動作しています。将来モデルが整理されて動かなくなった場合は、まずこの部分を、その時点で利用可能なモデル名に直してください。
使い方
- VSCodeでプロジェクトフォルダを開く(
.vscode/mcp.jsonが読み込まれる) - GitHub Copilot Chatを開き、エージェントモードにする
- 次のように話しかける
カメラで撮影して、Googleレンズのように撮影内容を解説して。
- 初回は「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研究所 で公開しています。
