★ 【GitHub Copilot】コードを書かずにMCPサーバーを作り、PCのカメラで撮影してAIに解説させる ★
【1】はじめに
Windows 11のPCに付いているWebカメラを、GitHub Copilot(VS Code)のプロンプトから使い、撮影した写真をGoogleレンズのようにAIに解説させる仕組みを作りました。
MCP(Model Context Protocol)は、AIアプリに外部のツールやデータを接続するための共通の仕組みです。
今回は「カメラで撮影する」「撮影してAIで解析する」という2つのツールを、自作のMCPサーバーとして提供し、GitHub Copilotから呼び出します。
この記事の特徴は、私はコードを1行も書いていない(触っていない)ことです。
コードはすべてAI(GeminiとGitHub Copilot)が作成しました。いわゆるバイブコーディングの記録として、経緯と失敗も含めて紹介します。
実際に動かすと、次のような結果が返ってきます。
カメラで撮影して、Googleレンズのように撮影内容を解説して。
撮影してGeminiで解析しました。画像には、セブンプレミアムのブラックコーヒーと思われるペットボトルが手前に写り、
中央には眼鏡をかけた短髪の人物、その後ろには木製の収納家具やリモコンが見えます。
商品名やボトルの残量は画像からの推定です。
撮影画像はcaptures\snapshot_1792896858357.bmpに保存されています。
システム構成図
【2】経緯
2-1.Geminiに相談
最初はGeminiに相談しました。私は「既存のMCPサーバーを探して、それを設定する」ことを想定していました。
ところが、Geminiは既存のカメラ用MCPサーバーを紹介せず、MCPサーバーを自作することを提案してきました。
カメラ用として適切な既存のサーバーが見つからなかったのかもしれません。他に選択肢もなかったので、この提案を採用しました。
2-2.Geminiのコードでは動かなかった
Geminiが出したコードは、そのままでは動きませんでした。主な問題は次のとおりです。
- MCP SDKの書き方が誤っていました。
server.onRequest(...)でハンドラーを登録していましたが、現行のSDKではserver.setRequestHandler(...)を使います。
- APIキーの設定方法が合いませんでした(詳細は【5】で説明します)。
- 撮影すると、BMPとJPGの2つのファイルができてしまいました。
そこで、GitHub Copilot(エージェントモード)にコードの修正を依頼しました。
以降はGitHub Copilotが修正を続け、最終的に動作するコードになりました。
BMPとJPGの問題は、BMPを削除するのではなく、「Windowsでは最初からBMPで撮影し、sharpでPNGに変換してGeminiへ送る」方式に変更されました。
2-3.役割分担
| 役割 | 担当 |
| 構成の提案、最初のコード | Gemini |
| コードの修正、環境変数の設定方法の再案内 | GitHub Copilot |
| APIキーの取得、環境変数の設定、動作確認 | 私(人間) |
【3】実行環境
使用した環境は次のとおりです。
- Windows 11(Webカメラ内蔵のPC)
- VS Code
- GitHub Copilot(Chatのエージェントモード。MCPのツールは、エージェントモードから呼び出されます)
- Node.js(事前にインストール済み)
- Gemini API(APIキーが必要)
Node.jsのパッケージ(npm)は、AIが選んだものをそのまま使っています。
| パッケージ | 用途 |
| @modelcontextprotocol/sdk | MCPサーバーのSDK |
| node-webcam | Webカメラでの撮影 |
| sharp | 画像の変換(BMP → PNG) |
| @google/genai | Gemini APIのクライアント |
仕組みは、システム構成図のとおりです。GitHub Copilotがツールを呼び出すと、MCPサーバー(Node.js)がカメラで撮影し、画像をGemini APIに送って解説を受け取り、結果をテキストでGitHub Copilotに返します。
撮影した画像は、MCPサーバーのフォルダ内のcapturesフォルダに保存されます。
【4】Gemini APIキーの取得
Google AI Studioで取得します。無料枠が用意されていますが、利用条件は変わることがあるので、AI Studioの画面で最新の内容を確認してください。
- ブラウザでGoogle AI Studioにアクセスし、Googleアカウントでログインする
- 初回は利用規約への同意画面が出るので、同意して進む
- 「Get API key」を開く
- 「Create API key」をクリックし、プロジェクトを選択(または新規作成)する
- 生成されたキーをコピーする
※ APIキーはパスワードと同じです。GitHubなどの公開リポジトリにアップロードしたり、SNSや記事のスクリーンショットに写り込ませたりしないよう注意してください。
【5】APIキーの設定(ここでつまずきました)
5-1.Geminiの案内(うまくいかなかった)
Geminiは、次のようにターミナルで設定する方法を案内しました。
(コマンドプロンプトの場合)
set GEMINI_API_KEY=あなたのAPIキー
(PowerShellの場合)
$env:GEMINI_API_KEY="あなたのAPIキー"
この方法は、そのターミナルのウィンドウの中だけで有効な設定です。
VS Codeをアイコンなどから起動した場合、この設定は引き継がれないため、MCPサーバーからキーが見えず、動作しませんでした。
5-2.GitHub Copilotに聞き直して解決
GitHub Copilotに聞き直したところ、ユーザー環境変数として登録する方法を案内されました。
- スタートメニューで「環境変数を編集」を検索して開く
- 「環境変数(N)...」を選び、「ユーザー環境変数」の「新規(N)...」をクリックする
- 次のように入力する
変数名:GEMINI_API_KEY
変数値:Google AI Studioで発行したAPIキー
- 開いているVS Codeをすべて終了してから、起動し直す
環境変数は、起動後のMCPサーバーにも引き継がれます。起動したままのVS Codeには反映されないので、いったん完全に終了して起動し直すことが重要です。
5-3.APIキーの扱い方
今回の構成では、APIキーをコードにも設定ファイルにも書いていません。キーがあるのは、Windowsのユーザー環境変数だけです。
server.jsは、process.env.GEMINI_API_KEYから実行時にキーを読み込みます。
.vscode/mcp.jsonにも、キーを渡す記述はありません。
このため、ソースコードをGitHubに公開したり、記事に貼ったりしても、キーは漏れません。
キーが設定されていない場合、撮影後のAI解析(take_and_analyze)は「GEMINI_API_KEY 環境変数が設定されていません。」というエラーを返します。
撮影のみのツール(take_snapshot)は、キーなしでも動きます。
キーが設定されているかどうかは、値を表示せずに確認できます。VS Codeを起動し直したあと、VS Codeのターミナル(PowerShell)で実行します。
if ($env:GEMINI_API_KEY) { "設定あり" } else { "未設定" }
※ キーの値を表示すると、スクリーンショットや画面共有で写り込む恐れがあるので、上記の方法にしています。
キーが漏れた可能性があるときは、Google AI Studioでキーを削除して、新しく発行し直してください。
【6】完成したMCPサーバー
最終的に、次の3つのファイルで動作しました。ファイルはすべてAIが作成・修正したもので、私は一切触っていません。
mcp-camera-server/
├── .vscode/
│ └── mcp.json
├── captures/ (撮影画像が保存される場所)
├── package.json
└── server.js
package.json(npm installで依存パッケージをインストールします)
{
"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"
}
}
.vscode/mcp.json(VS CodeのGitHub Copilotに、このサーバーを認識させる設定です)
{
"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つのツールを公開しています。
server.setRequestHandler(...)が、現行のMCP SDKでのハンドラー登録方法です。
- WindowsではBMPで撮影し、sharpでPNGに変換してからGeminiに送ります。PNGはファイルとして保存せず、メモリ上でBase64にして送信します。このため、
capturesフォルダにはBMPが保存されます。
- APIキーが未設定の場合は、分かりやすいエラーメッセージを返します。
※ モデル名のgemini-3.8-flashは、GitHub Copilotが書いたものがそのまま動作しています。
将来モデルが整理されて動かなくなった場合は、まずこの部分を、その時点で利用可能なモデル名に直してください。
【7】使い方
- VS Codeでプロジェクトのフォルダを開く(
.vscode/mcp.jsonが読み込まれます)
- GitHub Copilot Chatを開き、エージェントモードにする
- 「カメラで撮影して、Googleレンズのように撮影内容を解説して。」のように話しかける
- 初回は、MCPツールの実行を許可するかどうかの確認が表示されるので、「このセッションで許可する」を選ぶ
ツールの実行を許可する確認画面
カメラが一瞬動き、capturesフォルダに画像が保存され、Geminiによる解説がチャットに表示されます。
【8】感想と注意点
8-1.バイブコーディングで感じたこと
- 私はコードを1行も触っていません。やったのは、AIへの依頼、エラーや動作結果の報告、APIキーの取得と環境変数の設定、動作確認だけです。
- 最初のAIのコードがそのまま動くとは限りません。今回も、SDKの書き方とAPIキーの渡し方でつまずきました。
- そのようなときは、別のAIに聞き直す、または実行環境に近いAI(今回はVS Code内のGitHub Copilot)に直してもらうと、うまくいきました。
- 一方で、AIの出力を鵜呑みにせず、動作結果(写真と解説が合っているか)を自分の目で確認することは大切だと思います。
8-2.安全面の注意
- 撮影した画像は、Gemini APIに送信されます。個人情報、機密情報が写らないように注意してください。
- APIキーは公開しない、リポジトリにコミットしないようにしてください。
- MCPツールの実行には許可が必要です。意図しないカメラの起動を防ぐ仕組みなので、許可の内容を確認してから選んでください。
8-3.今後やってみたいこと
- ツールに質問の引数を追加し、「この商品の成分は?」「書かれている英語を翻訳して」のように、毎回の問いかけを指定できるようにする
- 撮影画像をMCPの画像として返し、GitHub Copilot自身にも見せる
- 撮影画像の保存形式をJPEGにして、容量を抑える
【9】おわりに
カメラ用の既存MCPサーバーを探すつもりが、Geminiの提案で自作することになりました。
Geminiのコードはそのままでは動かず、GitHub Copilotに修正してもらって完成しました。
APIキーは、ターミナルのコマンドではなく、Windowsのユーザー環境変数に登録してVS Codeを起動し直す必要がありました。
コードを一切触らずに、AIとの対話だけで、Googleレンズのようなカメラ解説ツールが動いたことには驚きました。
【改訂記録】
2026/10/25版:公開
[ホームへ戻る]