0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SalesforceをClaude Codeから操作する — REST API×自作MCPサーバーで営業事務を自動化した話

0
Last updated at Posted at 2026-09-28

商談のフォローメール送り忘れが月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/sdk 1.x / zod
  • Claude Code(MCP対応バージョン)

Step 1: 接続アプリを作り、OAuthを通す

Salesforce側の設定は次の3点です。

  1. 設定 → アプリケーションマネージャ → 新規接続アプリケーションを作成
  2. OAuth設定を有効化し、スコープに api を付与
  3. クライアント資格情報フロー(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件ありました。導入後の運用はこうです。

  1. Claude Codeが query_opportunities で停滞商談を抽出
  2. 商談ごとに、金額・完了予定日・取引先名を踏まえたフォローメールの下書きを生成
  3. 営業担当が内容を確認・微調整して送信する

完全自動送信にはあえてしていません。文面の最終判断は人間に残す設計にした結果、送り忘れは月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で叩いていますか? コメントで教えてもらえると助かります。

関連記事

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?