MCPサーバー自作入門【2026】広告データ・運用ナレッジをClaude Codeにつなぐ(TypeScript/Python実装)
Claude CodeなどのAIエージェントに「Google広告の先週の実績を見て」と頼んだとき、エージェントはどうやってデータにたどり着くのでしょうか。その“つなぎ方”の共通規格がMCP(Model Context Protocol)です。本記事では、広告媒体の実績データと社内の運用ナレッジを読み取り専用のMCPサーバーとして自作し、Claude Codeやチームのツールから安全に使えるようにするまでを、TypeScriptとPythonの実装付きで解説します。
- MCPはAIアプリと外部データ・ツールをつなぐ共通規格。一度サーバーを作れば、Claude Code・Claude Desktopなど対応アプリのどれからでも使える
- サーバーはtools(実行できる機能)/resources(読めるデータ)/prompts(定型プロンプト)を提供し、ローカルはstdio、リモートはStreamable HTTPでつなぐ
- ツールは「API1本=ツール1個」ではなく“知りたいこと”単位で設計し、返り値は短く要約、多ければページング
- 広告アカウントのようにお金が動くデータは読み取り専用から。書き込み系は作らないか、人の承認を必須にする
MCPとは:なぜ「標準化」が効くのか
MCP(Model Context Protocol)は、Anthropicが2024年11月に公開したオープンな規格です。AIアプリ(Claude Code、Claude Desktop、各種IDEなど)と、外部のツールやデータ(広告媒体、DB、社内ドキュメント、SaaSなど)を共通の手順でつなぐためのもので、よく「AIのUSB-C」と例えられます。
標準化がなぜ効くのかは、N×M問題で考えると分かりやすくなります。AIアプリがN個、つなぎたいデータ源がM個あるとき、それぞれ専用の連携を作るとN×M通りの実装が必要です。共通規格があれば、AIアプリ側は「MCPクライアント」を1回実装し、データ源側は「MCPサーバー」を1回実装するだけで済み、必要な実装はN+Mに減ります。
広告運用の現場に置き換えると、「媒体データを取ってくる部分」を一度MCPサーバーにしておけば、Claude Codeでの分析にも、チャット型のAIアシスタントにも、将来導入する別のエージェントにも同じ部品を使い回せるということです。


スクリプト方式の構成はClaude Code×広告媒体API連携入門で解説しています。本記事はその「取得部分」をMCPサーバーとして部品化する続編にあたります。
アーキテクチャ:ホスト・クライアント・サーバー
MCPには3つの登場人物がいます。
| 役割 | 説明 | 例 |
|---|---|---|
| ホスト | ユーザーが使うAIアプリ本体。LLMとの対話を管理する | Claude Code、Claude Desktop、IDE |
| クライアント | ホストの中で、MCPサーバー1つとの接続を受け持つ部品 | (ホストに内蔵) |
| サーバー | ツールやデータを提供するプログラム。今回作るもの | 広告実績サーバー、ナレッジ検索サーバー |
サーバーが提供できるものは3種類です。
| 種類 | 中身 | 誰が使うか | 広告での例 |
|---|---|---|---|
| tools | 実行できる機能(引数を受け取って結果を返す) | モデルが判断して呼ぶ | キャンペーン実績の集計、ナレッジ検索 |
| resources | 読めるデータ(URIで識別) | 主にアプリ・ユーザーが選んで渡す | 指標の定義、アカウントの運用ルール |
| prompts | 定型のプロンプトテンプレート | ユーザーが選んで使う | 「週次レビューの観点」テンプレート |
通信方式(トランスポート)は2つです。stdioはホストがサーバーを子プロセスとして起動し、標準入出力でやり取りする方式で、ローカルで自分だけが使う場合に向きます。Streamable HTTPはサーバーをHTTPで公開する方式で、チームで1つのサーバーを共有したり、クラウドで動かしたりする場合に使います(以前のHTTP+SSE方式に代わるものです)。
TypeScriptで最小のMCPサーバーを作る
公式のTypeScript SDK(@modelcontextprotocol/sdk)と、入力スキーマの定義に使うzodを入れます。
mkdir ad-insights-mcp && cd ad-insights-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
mkdir src{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}2つの数を足すだけの最小サーバーです。registerToolにツール名・説明・入力スキーマ・処理を渡し、stdioトランスポートにつなぎます。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "demo", version: "1.0.0" });
server.registerTool(
"add",
{
title: "Add",
description: "Add two numbers",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] })
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("demo MCP server running on stdio"); // ログは stderr へconsole.logでデバッグ出力を書くと通信が壊れ、クライアント側で謎のエラーになります。ログは必ずconsole.error(標準エラー出力)へ。Pythonでも同様にprint(..., file=sys.stderr)やloggingを使います。
実用例:広告実績と運用ナレッジを返すMCPサーバー
ここからが本題です。次の2つのツールと1つのリソースを持つad-insightsサーバーを作ります。
- get_campaign_performance:Google広告/Meta広告のキャンペーン別実績を集計して返す(費用・CV・CPA・CTR・CVR、ページング付き)
- search_ops_knowledge:社内の運用ナレッジ(CV定義・運用ルール・過去の施策メモ)をキーワード検索して抜粋を返す
- resource:指標の定義:CPAなどの計算式と注意点
データ源は、前回の記事の取得スクリプトが書き出すCSV(data/google_ads_daily.csvなど、date・platform・campaign_name・impressions・clicks・cost・conversionsの列構成)です。媒体APIの認証や呼び出しは取得スクリプト側に閉じ込め、MCPサーバーは取得済みデータを読むだけにしています。こうするとMCPサーバーは媒体の認証情報を一切持たずに済み、安全性とテストのしやすさが大きく上がります(直接APIを読む版は次章のPython実装で扱います)。
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// データの置き場所(取得スクリプトが書き出したCSV・運用ナレッジのMarkdown)
const DATA_DIR = process.env.AD_DATA_DIR ?? "./data";
const KNOWLEDGE_DIR = process.env.AD_KNOWLEDGE_DIR ?? "./knowledge";
const PAGE_SIZE = 10;
type Row = {
date: string; platform: string; campaign_name: string;
impressions: number; clicks: number; cost: number; conversions: number;
};
// 取得済みCSV(date,platform,campaign_id,campaign_name,impressions,clicks,cost,conversions)を読む
async function loadRows(platform: "google_ads" | "meta"): Promise<Row[]> {
const file = path.join(DATA_DIR, `${platform}_daily.csv`);
const text = await readFile(file, "utf-8"); // 無ければ例外 → 呼び出し側で isError に
const [header, ...lines] = text.trim().split(/\r?\n/);
const cols = header.split(",");
return lines.map((line) => {
const v = line.split(","); // 例示のため簡易パース。本番はCSVライブラリかDBを使う
const get = (k: string) => v[cols.indexOf(k)];
return {
date: get("date"), platform, campaign_name: get("campaign_name"),
impressions: Number(get("impressions")), clicks: Number(get("clicks")),
cost: Number(get("cost")), conversions: Number(get("conversions")),
};
});
}
const errorResult = (message: string) => ({
content: [{ type: "text" as const, text: message }],
isError: true, // ツールの失敗はプロトコルエラーではなく「結果」として返す
});
const server = new McpServer({ name: "ad-insights", version: "1.0.0" });
// ツール1:キャンペーン実績の集計(API1本=ツール1個ではなく「知りたいこと」単位)
server.registerTool(
"get_campaign_performance",
{
title: "キャンペーン実績の集計",
description:
"Google広告またはMeta広告のキャンペーン別実績(費用・CV・CPA・CTR・CVR)を、指定期間で集計して返す。" +
"『どのキャンペーンが悪化したか』『費用の大きい順』などを知りたいときに使う。" +
"日別の生データは返さない。結果が多い場合は next_cursor を使って続きを取得する。" +
"読み取り専用で、広告アカウントには一切変更を加えない。",
inputSchema: {
platform: z.enum(["google_ads", "meta"]).describe("媒体"),
days: z.number().int().min(1).max(90).default(7).describe("昨日から遡る日数"),
sort_by: z.enum(["cost", "cpa", "conversions"]).default("cost").describe("並び順(降順)"),
cursor: z.number().int().min(0).default(0).describe("ページング位置。初回は0"),
},
annotations: { readOnlyHint: true },
},
async ({ platform, days, sort_by, cursor }) => {
let rows: Row[];
try {
rows = await loadRows(platform);
} catch {
return errorResult(
`${platform} のデータが見つかりません。先に取得スクリプト(fetch_${platform}.py)を実行してください。`
);
}
const dates = [...new Set(rows.map((r) => r.date))].sort();
const target = new Set(dates.slice(-days));
const byCampaign = new Map<string, { cost: number; cv: number; imp: number; clk: number }>();
for (const r of rows.filter((r) => target.has(r.date))) {
const a = byCampaign.get(r.campaign_name) ?? { cost: 0, cv: 0, imp: 0, clk: 0 };
a.cost += r.cost; a.cv += r.conversions; a.imp += r.impressions; a.clk += r.clicks;
byCampaign.set(r.campaign_name, a);
}
const summary = [...byCampaign.entries()].map(([name, a]) => ({
campaign: name,
cost: Math.round(a.cost),
conversions: Math.round(a.cv * 10) / 10,
cpa: a.cv > 0 ? Math.round(a.cost / a.cv) : null, // CV0件は null(0円と誤読させない)
ctr: a.imp > 0 ? +(a.clk / a.imp * 100).toFixed(2) : null,
cvr: a.clk > 0 ? +(a.cv / a.clk * 100).toFixed(2) : null,
}));
summary.sort((x, y) => (y[sort_by] ?? -1) - (x[sort_by] ?? -1));
const page = summary.slice(cursor, cursor + PAGE_SIZE);
const next = cursor + PAGE_SIZE < summary.length ? cursor + PAGE_SIZE : null;
return {
content: [{
type: "text",
text: JSON.stringify({
platform, period: `${[...target][0]}〜${[...target].at(-1)}`,
total_campaigns: summary.length, campaigns: page, next_cursor: next,
note: "cost は円、ctr/cvr は%。cpa=cost/conversions",
}),
}],
};
}
);
// ツール2:運用ナレッジ(社内の運用ルール・過去の施策メモ)の検索
server.registerTool(
"search_ops_knowledge",
{
title: "運用ナレッジ検索",
description:
"社内の広告運用ナレッジ(アカウントごとの運用ルール、CV定義、過去の施策と結果のメモ)をキーワード検索し、" +
"該当箇所の抜粋を最大5件返す。数値の変動の原因仮説を立てる前や、改善提案をする前に使う。",
inputSchema: {
query: z.string().min(1).describe("検索語(例: 'P-MAX 除外', 'CV定義 Meta')"),
},
annotations: { readOnlyHint: true },
},
async ({ query }) => {
const words = query.toLowerCase().split(/\s+/);
const hits: string[] = [];
for (const f of await readdir(KNOWLEDGE_DIR)) {
if (!f.endsWith(".md")) continue;
const text = await readFile(path.join(KNOWLEDGE_DIR, f), "utf-8");
for (const para of text.split(/\n{2,}/)) {
if (words.every((w) => para.toLowerCase().includes(w))) {
hits.push(`[${f}] ${para.slice(0, 400)}`); // 抜粋は短く
}
}
}
return {
content: [{
type: "text",
text: hits.length ? hits.slice(0, 5).join("\n---\n") : "該当するナレッジはありません。",
}],
};
}
);
// リソース:指標の定義(Claudeや人が「読む」ためのデータ)
server.registerResource(
"metric-definitions",
"docs://ad-insights/metrics",
{ title: "指標の定義", description: "CPA・CVR等の計算式と注意点", mimeType: "text/markdown" },
async (uri) => ({
contents: [{
uri: uri.href,
text: "- CPA = cost / conversions(CV0件は算出しない)\n- CTR = clicks / impressions\n- CVR = conversions / clicks\n- Google広告の費用は cost_micros / 1,000,000",
}],
})
);
await server.connect(new StdioServerTransport());
console.error("ad-insights MCP server running on stdio");運用ナレッジは、Markdownファイルをknowledge/に置いておくだけです。
# クライアントA 運用メモ(knowledge/client_a.md)
## CV定義
Google広告は「購入完了」のみをメインCVにしている。Meta の CV定義は purchase。
## 過去の施策
2026年8月、P-MAX に除外キーワード(指名系)を追加。指名検索キャンペーンとの食い合いを抑える目的。実装のポイント
- descriptionは「いつ使うか」まで書く:モデルはdescriptionを読んでツールを選びます。「何を返すか」だけでなく「どんな質問のときに使うか」「何は返さないか」「読み取り専用であること」を書くと、呼び出しの精度が上がります
- 引数はenumと範囲で絞る:
platformはenum、daysは1〜90。自由入力を減らすほど誤った呼び出しが減ります - 失敗は
isError: trueで「結果」として返す:データがないときに例外で落とすのではなく、「先に取得スクリプトを実行してください」という次の行動が分かるメッセージを返すと、エージェントが自分でリカバリーできます - CV0件のCPAは null:0円と返すと「最も効率のよいキャンペーン」と誤読されます
readOnlyHintアノテーション:ツールが読み取り専用であることをクライアントに伝えるヒントです。ただしあくまで自己申告なので、安全性の根拠にはしない(信頼できないサーバーの自己申告は信用できない)点に注意してください


Python版:Google広告APIを直接読むサーバー
Python SDK(mcpパッケージ)では、関数にデコレーターを付けるだけでツールになります。型ヒントとFieldの説明から入力スキーマが自動生成されるので、TypeScript版よりさらに短く書けます。ここでは公式ライブラリgoogle-adsでGoogle広告APIを直接読む版を作ります。
FastMCP(from mcp.server.fastmcp import FastMCP)という名前でした。執筆時点の最新であるv2系ではMCPServer(from mcp.server.mcpserver import MCPServer)に改名され、ほかにもAPIの変更があります。ネット上のサンプルの多くはv1系の書き方なので、pip listでバージョンを確認し、公式の移行ガイドを参照してください。デコレーターでツールを定義する基本の書き方は共通です。
"""Google広告の実績をMCPツールとして公開する(読み取り専用)"""
import os
import sys
from functools import cache
from typing import Annotated, Literal
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
from mcp.server.mcpserver import MCPServer # v1系: from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import Field
mcp = MCPServer("google-ads-readonly")
@cache
def ads_client() -> GoogleAdsClient:
"""初回呼び出し時に1回だけ認証(起動時に認証すると、失敗でサーバーごと落ちる)"""
return GoogleAdsClient.load_from_storage(os.environ["GOOGLE_ADS_CONFIG"])
# 触ってよいアカウントを許可リストで固定(モデルに任意のIDを指定させない)
ALLOWED_CUSTOMERS = set(os.environ["GOOGLE_ADS_ALLOWED_CUSTOMERS"].split(","))
@mcp.tool()
def get_campaign_performance(
customer_id: Annotated[str, Field(description="広告アカウントID(10桁)。許可されたアカウントのみ")],
period: Annotated[
Literal["LAST_7_DAYS", "LAST_14_DAYS", "LAST_30_DAYS"], Field(description="集計期間")
] = "LAST_7_DAYS",
limit: Annotated[int, Field(ge=1, le=50, description="返すキャンペーン数の上限")] = 20,
) -> str:
"""Google広告のキャンペーン別実績(費用・CV・CPA)を費用の大きい順に返す。
『今週どのキャンペーンにお金が使われ、CPAはいくらか』を知りたいときに使う。読み取り専用。"""
customer_id = customer_id.replace("-", "")
if customer_id not in ALLOWED_CUSTOMERS:
# ToolError はメッセージ付きの isError: true としてモデルに返る
raise ToolError(f"アカウント {customer_id} は許可されていません")
query = f"""
SELECT campaign.name, metrics.cost_micros, metrics.conversions, metrics.clicks
FROM campaign
WHERE segments.date DURING {period} AND campaign.status = 'ENABLED'
ORDER BY metrics.cost_micros DESC
LIMIT {limit}
"""
try:
service = ads_client().get_service("GoogleAdsService")
lines = []
for batch in service.search_stream(customer_id=customer_id, query=query):
for row in batch.results:
cost = row.metrics.cost_micros / 1_000_000
cv = row.metrics.conversions
cpa = f"{cost / cv:,.0f}円" if cv > 0 else "CVなし"
lines.append(f"{row.campaign.name}: 費用{cost:,.0f}円 / CV{cv:.1f} / CPA {cpa}")
except GoogleAdsException as ex:
messages = "; ".join(e.message for e in ex.failure.errors)
raise ToolError(f"Google Ads API エラー: {messages}") from ex
return "\n".join(lines) if lines else "該当期間に配信中のキャンペーンはありません。"
if __name__ == "__main__":
print("google-ads-readonly MCP server starting", file=sys.stderr) # stdout は使わない
mcp.run() # 既定は stdiopython3 -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]" google-ads
# 単体テスト:Inspector から起動
npx @modelcontextprotocol/inspector python server.py
# Claude Code に登録(秘密情報は -e で渡し、コードやGitに書かない)
claude mcp add google-ads-readonly \
-e GOOGLE_ADS_CONFIG=$HOME/.config/ad-analytics/google-ads.yaml \
-e GOOGLE_ADS_ALLOWED_CUSTOMERS=1234567890 \
-- /path/to/.venv/bin/python /path/to/server.pyTypeScript版との設計上の違いと、その理由です。
- アカウントIDを許可リストで固定:MCC配下の全アカウントにアクセスできる認証情報でも、サーバーが触れるのは
GOOGLE_ADS_ALLOWED_CUSTOMERSに列挙したアカウントだけにします。モデルが別のIDを指定しても拒否します - 期間はenum:GAQLの
DURING句に入れる値を選択肢に限定することで、クエリへの任意文字列の混入も防いでいます - 想定内の失敗は
ToolError:v2系では、ToolErrorを送出するとメッセージ付きのisError: trueとしてモデルに返ります。それ以外の想定外の例外は「Error executing tool …」とだけ返り、詳細はサーバーのログに残ります(内部情報がモデルに漏れない設計です) - 認証は初回呼び出し時:起動時に認証すると、トークン切れのときサーバーごと起動に失敗し、クライアント側では原因が見えにくくなります
MCP Inspectorでテストする
作ったサーバーは、いきなりClaude Codeにつなぐ前にMCP Inspectorで単体テストします。Inspectorはブラウザで動く公式のデバッグツールで、サーバーを起動してツール一覧の表示・引数を指定した実行・resourcesの読み取りができます。
npx tsc # src/ → build/ にコンパイル
npx @modelcontextprotocol/inspector node build/index.js
# ブラウザで Inspector が開く → Connect → Tools タブで一覧表示・実行確認すべきポイントは次のとおりです。
- ツールのdescriptionと入力スキーマが意図どおりに見えるか(モデルが見るのはこれだけ)
- 異常系(存在しない媒体のデータ、許可外のアカウントID、範囲外のdays)で
isError: trueと分かりやすいメッセージが返るか - 返り値の長さが適切か(1回の結果が長すぎないか、ページングが機能するか)
Claude Code・Claude Desktopにつなぐ(.mcp.jsonでチーム共有)
Claude Codeにはclaude mcp addコマンドで登録します。--の後ろがサーバーの起動コマンドです。
# 自分だけで使う(local スコープ)
claude mcp add ad-insights -- node /path/to/ad-insights-mcp/build/index.js
# 環境変数を渡す
claude mcp add ad-insights -e AD_DATA_DIR=/path/to/data -e AD_KNOWLEDGE_DIR=/path/to/knowledge \
-- node /path/to/ad-insights-mcp/build/index.js
# チームで共有する(プロジェクト直下の .mcp.json に保存 → Git で共有)
claude mcp add ad-insights --scope project -- node ./mcp/ad-insights/build/index.js
# 確認
claude mcp list
# Claude Code の中では /mcp で接続状況とツール一覧を確認できる--scope projectで登録すると、プロジェクト直下の.mcp.jsonに設定が保存されます。これをGitにコミットすれば、チーム全員が同じMCPサーバー構成を使えるようになります。秘密情報は${環境変数}の形で参照し、ファイルには書かないようにします。なお、プロジェクトの.mcp.jsonに書かれたサーバーは、初回利用時にClaude Codeが使用してよいか確認を求めます。
{
"mcpServers": {
"ad-insights": {
"command": "node",
"args": ["./mcp/ad-insights/build/index.js"],
"env": {
"AD_DATA_DIR": "./data",
"AD_KNOWLEDGE_DIR": "./knowledge"
}
},
"google-ads-readonly": {
"command": "python",
"args": ["./mcp/google_ads/server.py"],
"env": {
"GOOGLE_ADS_CONFIG": "${GOOGLE_ADS_CONFIG}",
"GOOGLE_ADS_ALLOWED_CUSTOMERS": "${GOOGLE_ADS_ALLOWED_CUSTOMERS}"
}
}
}
}Claude Desktopでは、設定ファイルclaude_desktop_config.jsonに同じ形式で書きます(設定画面の開発者向けメニューから開けます。場所はOSやバージョンで異なるため公式ドキュメントで確認してください)。
{
"mcpServers": {
"ad-insights": {
"command": "node",
"args": ["/Users/you/ad-insights-mcp/build/index.js"],
"env": { "AD_DATA_DIR": "/Users/you/ad-analytics/data" }
}
}
}接続できたら、Claude Codeで「先週のGoogle広告でCPAが高い順に見せて。過去の施策メモも踏まえて原因を考えて」と頼んでみましょう。get_campaign_performanceで集計を取り、search_ops_knowledgeで過去の施策(たとえば「P-MAXに除外キーワードを追加した」)を引いて、両方を踏まえた仮説を返してくるはずです。データとナレッジを同じ場所から引けるのが、MCP化の実利です。
リモート化:Streamable HTTPとOAuth
チームの複数人・複数のAIツールから1つのサーバーを使うなら、Streamable HTTPでリモート公開します。TypeScript SDKでは、ExpressなどのサーバーにStreamableHTTPServerTransportを組み込みます。以下はセッションを持たない(ステートレスな)最小構成です。
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
function buildServer(): McpServer {
const server = new McpServer({ name: "ad-insights-remote", version: "1.0.0" });
server.registerTool(
"ping",
{ title: "Ping", description: "疎通確認用", inputSchema: { message: z.string() } },
async ({ message }) => ({ content: [{ type: "text", text: `pong: ${message}` }] })
);
return server;
}
const app = express();
app.use(express.json());
// ステートレス構成:リクエストごとにサーバーとトランスポートを作る
app.post("/mcp", async (req, res) => {
// 本番ではここより前段で OAuth のアクセストークンを検証する(後述)
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.error("MCP server listening on http://localhost:3000/mcp"));# リモート(Streamable HTTP)の MCP サーバーを登録
claude mcp add --transport http ad-insights-remote https://mcp.example.com/mcp
# OAuth 対応サーバーなら、Claude Code 内の /mcp から認証フローを開始できる認証はOAuthで(概念)
リモートのMCPサーバーを認証なしで公開するのは論外です。MCPの仕様では、リモートサーバーの認可にOAuth 2.1ベースの仕組みが定められています。考え方は次のとおりです。
- MCPサーバーはリソースサーバーとして振る舞い、リクエストに付いたアクセストークンを検証する
- トークンの発行は認可サーバー(社内のIdPなど)に任せ、ユーザーはブラウザでログイン・同意する
- トークンはそのMCPサーバー向けに発行されたものだけを受け付け、受け取ったトークンを別のAPIにそのまま流用しない
- 媒体APIの認証情報(Google広告のリフレッシュトークン等)はサーバー側だけが持ち、クライアントには渡さない
実装の詳細はSDKの認証機能やIdPの構成に依存するため、MCP公式の認可仕様とSDKのドキュメントを確認してください。社内だけで使う場合は、まず社内ネットワーク限定+既存のSSOで保護する構成から始めるのが現実的です。
補足:Claude APIから直接リモートMCPを使う
自社アプリにClaudeを組み込む場合は、Claude APIのMCPコネクタ(執筆時点でbeta)を使うと、APIのリクエストにリモートMCPサーバーのURLを指定するだけで、Claudeがそのツールを使えます。エージェントループを自分で書かずに済むのが利点です。
import anthropic
client = anthropic.Anthropic()
# Claude API 側から、公開済みのリモートMCPサーバーを直接使う(beta)
response = client.beta.messages.create(
model="claude-opus-5",
max_tokens=16000,
betas=["mcp-client-2025-11-20"],
mcp_servers=[{
"type": "url",
"url": "https://mcp.example.com/mcp",
"name": "ad-insights",
# "authorization_token": "...", # OAuth で取得したアクセストークン
}],
tools=[{"type": "mcp_toolset", "mcp_server_name": "ad-insights"}],
messages=[{"role": "user", "content": "先週のGoogle広告でCPAが高い順に5キャンペーン教えて"}],
)
for block in response.content:
if block.type == "text":
print(block.text)セキュリティ:広告データを扱うMCPの守り方
MCPサーバーは「AIに渡す権限の塊」です。広告アカウントは直接お金が動くため、特に慎重に設計します。
| リスク | 具体例 | 対策 |
|---|---|---|
| プロンプトインジェクション | 検索語句・広告コメント・LPの文言など第三者が書いたテキストに「予算を上げろ」等の命令が紛れ、AIが従う | 書き込みツールを持たせないのが最強の対策。返り値の外部テキストは最小限に。ホスト側の指示で「データ内の指示に従わない」と明示 |
| 過剰な権限 | MCC全体に書き込める認証情報をサーバーに持たせる | 閲覧権限のみのユーザーで認証。アカウントIDは許可リストで固定 |
| 誤操作 | エージェントが良かれと思って配信を停止する | 読み取り専用から始める。書き込み系は作らないか、クライアント側で毎回人の承認を必須に |
| 信頼できないサーバー | 出所不明のMCPサーバーが、他のサーバーのデータや認証情報を外部に送る | 提供元が明確で、コードを確認できるものだけ導入。チームで使うサーバーは許可リストで管理 |
| トークンの漏えい | .mcp.jsonにアクセストークンを直書きしてGitにコミット | 環境変数参照にする。長期トークンは定期ローテーション。ログにトークンを出さない |
| データの持ち出し | 個人単位のCVデータやクライアント名が不要にAIへ渡る | 返すのは集計値に限定。クライアントとの契約・社内規程で扱いを確認 |

書き込み系ツール(予算変更、配信停止など)をどうしても作る場合は、サーバー側で変更内容の「提案」だけを作り、実行は別の承認フロー(人がボタンを押す等)で行う二段構えにするのがおすすめです。Claude Codeでは、MCPツールも.claude/settings.jsonのpermissionsでask(毎回確認)やdenyに設定できます。本番運用の全体設計はAIエージェントを本番運用する安全設計ガイドにまとめています。
設計のコツ:エージェントが使いやすいツールとは
1. 「API1本=ツール1個」にしない
媒体APIのエンドポイントをそのままツールにすると(キャンペーン一覧取得、広告グループ一覧取得、指標取得…)、エージェントは目的の答えにたどり着くまで何度もツールを呼び、途中の生データでコンテキストを埋めてしまいます。ツールは「ユーザーが知りたいこと」単位で作ります。
| 悪い例(API単位) | 良い例(ユースケース単位) |
|---|---|
| list_campaigns / get_metrics / get_ad_groups を順に呼ばせる | get_campaign_performance(期間・並び順を指定すると集計済みの表が返る) |
| get_daily_rows で全日別データを返す | compare_periods(前週比の変化が大きいキャンペーンだけ返す) |
| 汎用の run_gaql(任意のクエリを実行) | 目的別に数個のツール。任意クエリは権限・安全面でも避ける |
2. 返り値は短く、要約して
ツールの返り値はそのままモデルのコンテキストに入ります。単位や定義の注記を添えた、答えに近い形で返しましょう。今回の実装でnoteフィールドに「costは円、cpa=cost/conversions」と入れているのは、モデルが単位を誤解しないためです。
3. 多いときはページング
キャンペーンが数百あるアカウントでは、全件を返すとコンテキストを圧迫します。上位N件とnext_cursorを返し、必要ならモデルに続きを取らせます。total_campaigns(全体の件数)も返すと、モデルが「全体のうちどれだけ見たか」を判断できます。
4. ツールは少なく、説明は厚く
ツールが増えるほど、モデルが「どれを使うか」で迷い、誤選択が増えます。似たツールはまとめ、各ツールのdescriptionに使う場面・使わない場面を書きます。ツール名もget_campaign_performanceのように、動詞+対象で一目で分かるものにします。
当社の構想と、業界標準化の流れ
「媒体データを見に行く」作業をMCP化する設計例
当社の本業である広告運用では、運用者が1日に何度も「管理画面を開いて数字を確認する」作業をしています。この作業をMCP化する構想を、次の方針で設計しています。
- 第1段階:読み取り専用。実績の集計、前週比の変化、運用ナレッジ(CV定義・過去の施策)の検索だけを提供し、書き込み系ツールは作らない
- データとナレッジを同じ窓口に。「数字が悪化した」だけでなく「そのアカウントでは先月こういう施策をした」という文脈を同時に引けるようにし、仮説の質を上げる
- 計算はサーバー側、解釈はAI側。CPAなどの指標はサーバーで計算して返し、モデルには単位と定義の注記を必ず添える
- アカウント単位の許可リスト。担当者・案件ごとに触れるアカウントを限定し、誤って別クライアントのデータを参照しない構成にする
スクリプト方式で運用を固めてからMCPに部品化する、という順番も重視しています(いきなり汎用サーバーを作ると、ツールの粒度が現場の使い方と合わないため)。
MCPが業界標準になった流れ(公開情報ベース)
MCPはAnthropicが2024年11月に公開した後、他社のAIプラットフォームにも広がりました。OpenAIは2025年にAgents SDKなどでのMCP対応を発表し、GoogleもGeminiでのMCP対応を表明、MicrosoftもCopilot StudioやWindowsなど自社製品でのMCP対応を打ち出したと報じられています。こうした流れにより、MCPは特定ベンダーの仕組みではなくAIエージェントと外部ツールをつなぐ事実上の業界標準と見なされるようになりました。媒体側でも、GoogleがGoogle広告API向けのMCPサーバー(読み取り中心)を公開しています(執筆時点)。公開サーバーで要件を満たせるならそれを使い、自社の運用ルールやナレッジと組み合わせたい部分だけを自作する、という使い分けが現実的です。
よくある質問
MCPサーバーと普通のAPIは何が違う?
普通のAPIは人間の開発者がコードから呼ぶための窓口で、呼び出し方はAPIごとにばらばらです。MCPサーバーはAIアプリが呼ぶための共通規格の窓口で、ツールの説明や入力スキーマを決まった形で提供するため、MCP対応のAIアプリならどれからでも同じように使えます。多くの場合、MCPサーバーの内部で普通のAPIを呼んでいます。
公開されているMCPサーバーと自作、どちらを使うべき?
要件を満たす公開サーバーがあり、提供元が信頼でき、権限を読み取りに絞れるならまず公開サーバーを使うのが早道です。自社の指標定義や運用ナレッジと組み合わせたい、触れるアカウントを細かく制限したい、返り値を業務に合わせて要約したい、といった場合は自作が向いています。
stdioとStreamable HTTPはどう使い分ける?
自分のPCで自分だけが使うならstdioで十分で、認証も不要なため最初の一歩に向いています。チームの複数人や複数のAIツールから1つのサーバーを共有したい、クラウドで動かしたい場合はStreamable HTTPでリモート化し、OAuthなどの認証を必ず組み合わせます。
MCPサーバーに広告の書き込み系ツールを作ってもいい?
最初は作らないことをおすすめします。プロンプトインジェクションや誤判断で予算変更や配信停止が実行されるリスクがあるためです。必要になった場合も、サーバーは変更の提案だけを作り、実行は人の承認を経る二段構えにし、クライアント側でも毎回確認が出る設定にしてください。
まとめ|「規格品の道具」にして、読み取りから始める
MCPサーバーを自作すると、広告媒体のデータや運用ナレッジを、Claude Codeをはじめとする対応AIアプリから共通の方法で使えるようになります。TypeScriptでもPythonでも、最小のサーバーは数十行です。大事なのはコードの量ではなく、ユースケース単位のツール設計、短く要約した返り値、読み取り専用から始める権限設計の3点です。
まずはスクリプト方式で分析の型を作るClaude Code×広告媒体API連携入門、レポート作成まで自動化する広告運用レポートをClaude Codeで自動化する方法、GA4・Search ConsoleとつなぐClaude CodeでGA4・Search Console APIをつなぐ記事もあわせてどうぞ。APIやJSONの基礎から学びたい方はIT基礎ロードマップから読み始めてください。
AIエージェント開発のスキルを、キャリアにつなげる
LLM APIでエージェントを実装できるエンジニアは、転職・独立どちらでも需要が高い領域です。
- AIで独立するには?未経験から案件を取る6ステップ【2026】:自動化・開発スキルを案件にする。フリーランスエージェントの選び方も
- 仕事用の外付けモニターおすすめ3選【2026】:エディタ・ターミナル・ブラウザを並べると作業効率が段違い