商談のフォローメール送り忘れが月5件。ある案件でこの数字を聞いたとき、原因はすぐ想像がつきました。営業担当がSalesforceの画面を開いて、対象商談を絞り込んで、メールを書く——この一連の「画面またぎ」が面倒だから後回しになるのです。
私はこの課題を Salesforce REST API → 自作MCPサーバー → Claude Code という構成で解決しました。結果、フォローの送り忘れはゼロになり、初回レスポンスは平均3日→翌日まで縮みました。この記事では、その構成をコード付きで再現できるように書きます。
Salesforce連携の記事はApex側から書かれたものが多いですが、本記事は「外部からAPIで叩き、判断と文章生成はClaude Codeに任せる」という切り口です。Salesforce管理者でなくても、REST APIとNode.jsが分かれば追いかけられる内容です(APIを叩ける権限とアクセス設定は必要です)。
なぜブラウザ自動化ではなくAPI×MCPなのか
最初に判断の分かれ道がありました。選択肢は3つです。
| 選択肢 | 初期コスト | 保守性 | 判断・文章生成 |
|---|---|---|---|
| ブラウザ自動化(Playwright等) | 低 | 低(UI変更で壊れる) | できない |
| REST APIを都度スクリプトで叩く | 中 | 高 | できない |
| REST API + MCPサーバー + Claude Code | 中 | 高 | できる |
私は以前、別の案件で自治体向けWebフォームへの月次報告入力をPlaywrightで自動化したことがあります。入力作業は3時間→10分に短縮できたものの、画面のUI変更のたびにセレクタが壊れ、保守に追われました。CSSセレクタではなくdata属性で要素を指定するなどの延命策はありますが、根本的には「画面をなぞる自動化」は画面の都合に振り回されます。APIが使えるならブラウザ自動化より先にそちらを検討する、というのがこのとき得た教訓です。
もう1つの背景は、SaaSの構成です。以前社内のSaaS棚卸しをやったとき、35個あったサービスを22個まで減らしましたが、SalesforceのようなコアSaaSは削減対象になりません。残ると決めたSaaSこそ、API連携のハブに据える価値がある。単発スクリプトの山ではなくMCPサーバーとして整備したのは、「商談を検索する」「ステージを更新する」といった操作を再利用可能な道具にして、判断や文章化をClaude Codeに任せたかったからです。
全体構成
Salesforce (REST API)
↑ OAuth 2.0 Client Credentials Flow
自作MCPサーバー (Node.js / TypeScript)
↑ stdio (MCP)
Claude Code ←「先週から動いていない商談を出して、フォロー文面を下書きして」
MCPサーバーは「Salesforceの操作をツールとして公開する」だけの薄い層にして、SOQLの組み立てや文面生成の判断はClaude Code側に寄せています。
前提条件
私が構築したときの環境です。
- Salesforce: Enterprise Edition(API利用可能なエディションであること。Essentialsは不可)
- 権限: 接続アプリケーション(Connected App)を作成できるシステム管理者権限
- API バージョン: v63.0
- Node.js 22 / TypeScript 5系
-
@modelcontextprotocol/sdk1.x /zod - Claude Code(MCP対応バージョン)
Step 1: 接続アプリを作り、OAuthを通す
Salesforce側の設定は次の3点です。
- 設定 → アプリケーションマネージャ → 新規接続アプリケーションを作成
- OAuth設定を有効化し、スコープに
apiを付与 - クライアント資格情報フロー(Client Credentials Flow)を有効化し、「実行ユーザー」に連携用ユーザーを割り当てる
3が肝です。私は最初ここを飛ばしてトークン取得が invalid_grant で通らず、30分ほど溶かしました。サーバー間連携ならリフレッシュトークン管理が不要なClient Credentials Flowが最も運用が楽です。
トークン取得はcurlで先に疎通確認しておくと切り分けが早いです。
curl -s -X POST "https://your-domain.my.salesforce.com/services/oauth2/token" \
-d "grant_type=client_credentials" \
-d "client_id=$SF_CLIENT_ID" \
-d "client_secret=$SF_CLIENT_SECRET"
注意点として、URLは login.salesforce.com ではなく 自組織のMy DomainのURL を使います。私の環境ではここを間違えてもエラーメッセージからは原因が分からず、これも切り分けに時間を食いました。
Step 2: MCPサーバーを実装する
商談の検索と更新をツールとして公開する最小構成です。認証まわりは「トークンをキャッシュし、401が返ったら1回だけ再取得してリトライ」にしています。
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: "salesforce", version: "1.0.0" });
const API = "/services/data/v63.0";
let token: string | null = null;
async function getToken(): Promise<string> {
if (token) return token;
const res = await fetch(`${process.env.SF_INSTANCE_URL}/services/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.SF_CLIENT_ID!,
client_secret: process.env.SF_CLIENT_SECRET!,
}),
});
if (!res.ok) throw new Error(`token error: ${await res.text()}`);
token = (await res.json()).access_token;
return token!;
}
async function sfFetch(path: string, init: RequestInit = {}, retried = false): Promise<any> {
const res = await fetch(`${process.env.SF_INSTANCE_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (res.status === 401 && !retried) { // セッション失効 → 再取得して1回だけ再試行
token = null;
return sfFetch(path, init, true);
}
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
server.tool(
"query_opportunities",
"商談をステージ名と最終更新日で検索する",
{ stage: z.string(), notUpdatedDays: z.number().int().min(1).max(90) },
async ({ stage, notUpdatedDays }) => {
const soql =
`SELECT Id, Name, StageName, Amount, CloseDate, Owner.Name, Account.Name ` +
`FROM Opportunity WHERE StageName = '${stage.replace(/(['\\])/g, "\\$1")}' ` +
`AND LastModifiedDate < LAST_N_DAYS:${notUpdatedDays} AND IsClosed = false ` +
`ORDER BY CloseDate LIMIT 50`;
const data = await sfFetch(`${API}/query?q=${encodeURIComponent(soql)}`);
return { content: [{ type: "text", text: JSON.stringify(data.records) }] };
}
);
server.tool(
"update_opportunity_stage",
"商談のステージを更新する",
{ id: z.string().regex(/^[a-zA-Z0-9]{15,18}$/), stage: z.string() },
async ({ id, stage }) => {
await sfFetch(`${API}/sobjects/Opportunity/${id}`, {
method: "PATCH",
body: JSON.stringify({ StageName: stage }),
});
return { content: [{ type: "text", text: `updated: ${id} -> ${stage}` }] };
}
);
await server.connect(new StdioServerTransport());
設計上こだわったのは2点です。
-
SOQLを組み立てるのはツール側、条件を決めるのはClaude Code側。自由なSOQL実行ツール(
run_soql)も作りましたが、更新系と同居させると事故が怖いので、書き込みは専用ツールに分離し、入力をzodで厳しめに検証しています。 - ツールのdescriptionは日本語で業務語彙に寄せる。「商談」「ステージ」と書いておくと、Claude Codeが自然言語の依頼から適切なツールを選ぶ精度が体感で明らかに上がります。
Step 3: Claude Codeに登録する
プロジェクト直下の .mcp.json に登録します。
{
"mcpServers": {
"salesforce": {
"command": "node",
"args": ["./mcp-salesforce/dist/index.js"],
"env": {
"SF_INSTANCE_URL": "https://your-domain.my.salesforce.com",
"SF_CLIENT_ID": "${SF_CLIENT_ID}",
"SF_CLIENT_SECRET": "${SF_CLIENT_SECRET}"
}
}
}
}
client_secretを平文で置かず、環境変数参照にしてシェル側(direnv等)で注入します。.mcp.json をリポジトリ共有する場合はここが必須です。
これで Claude Code に「『提案済み』のまま7日以上動いていない商談を一覧して」と話しかけると、query_opportunities が呼ばれて実データが返ってきます。
Step 4: 実運用 — フォロー下書きの自動生成と日次バッチ
冒頭の課題に戻ります。この案件では「提案済み」ステージのまま放置される商談のフォローメール送り忘れが月5件ありました。導入後の運用はこうです。
- Claude Codeが
query_opportunitiesで停滞商談を抽出 - 商談ごとに、金額・完了予定日・取引先名を踏まえたフォローメールの下書きを生成
- 営業担当が内容を確認・微調整して送信する
完全自動送信にはあえてしていません。文面の最終判断は人間に残す設計にした結果、送り忘れは月5件→ゼロ、初回レスポンスは平均3日→翌日になり、四半期で見ると成約率が15%向上しました。「下書きまで自動、送信は人間」が営業メールの現実解だと思っています。
もう1つは定時実行です。毎朝の停滞商談チェックは、当初私が手で回していましたが、以前別の日次集計バッチで「担当者の体調不良で3日止まって月次レポートに穴が空いた」経験があるので、早々にcron化しました。Claude Codeをheadlessモード(claude -p)でcronから起動し、結果をSlackに通知、失敗時はエラーログ付きでアラートを飛ばします。この構成で3ヶ月、遅延・漏れなく安定稼働しています。
ハマったポイント一覧
私が実際に踏んだものだけを載せます。
| 症状 | 原因 | 解決 |
|---|---|---|
invalid_grant でトークンが取れない |
接続アプリで実行ユーザー未設定 | Client Credentials Flowの「実行ユーザー」を割り当てる |
| 認証が通ったり通らなかったりする |
login.salesforce.com に投げていた |
My DomainのURLに統一 |
INVALID_FIELD |
項目を表示ラベルで指定していた | API参照名を使う(カスタム項目は __c 付き)。/sobjects/Opportunity/describe で確認 |
MALFORMED_QUERY |
SOQL内のシングルクォート未エスケープ | 入力エスケープ+zodで形式検証 |
REQUEST_LIMIT_EXCEEDED |
日次APIコール上限に接触 | レスポンスヘッダ Sforce-Limit-Info を監視し、一覧系はまとめて取得 |
| 昼過ぎから突然401 | アクセストークン失効 | 401時に1回だけ再取得してリトライ(上記コード参照) |
特に INVALID_FIELD は、Salesforce管理画面の表示名とAPI参照名が別物であることを知らないと必ず踏みます。私は初日に「商談名」でクエリを書いて撃沈しました。
Salesforce REST API 早見表
MCPサーバーにツールを足すとき、私がいつも参照している最小セットです。
| 目的 | メソッド / エンドポイント |
|---|---|
| SOQL実行 | GET /services/data/v63.0/query?q={SOQL} |
| レコード取得 | GET /services/data/v63.0/sobjects/{Object}/{Id} |
| レコード作成 | POST /services/data/v63.0/sobjects/{Object} |
| レコード更新 | PATCH /services/data/v63.0/sobjects/{Object}/{Id} |
| 項目定義の確認 | GET /services/data/v63.0/sobjects/{Object}/describe |
| 複数リクエスト一括 | POST /services/data/v63.0/composite |
| API使用量の確認 | 各レスポンスヘッダ Sforce-Limit-Info: api-usage=…
|
まとめ
- Salesforce連携は REST API + Client Credentials Flow が外部サーバーからは最も運用が楽
- MCPサーバーは「操作を道具として公開する薄い層」に徹し、判断と文章生成はClaude Codeに任せる
- 書き込み系ツールは検索系と分離し、入力検証を厳しくする
- 「下書きまで自動、送信は人間」で送り忘れ月5件→ゼロ、レスポンス3日→翌日
結局のところ、今回いちばん効いたのは技術選定そのものより「画面をなぞる自動化」から「データモデルを直接操作する自動化」へ考え方を切り替えたことでした。UIは変わりますが、OpportunityやAccountというデータモデルはめったに変わりません。壊れにくい場所に自動化を置く——Salesforceに限らず、SaaS連携全般で使える判断基準だと私は思っています。
私はMCPサーバー自作に落ち着きましたが、もっと良い手があれば知りたいところです。Salesforce連携、みなさんはApexやFlowでSalesforce側に寄せていますか? それとも外部からAPIで叩いていますか? コメントで教えてもらえると助かります。