$ cat post.metadata

MCPサーバーでCRMを統合する -- Claude CodeからHubSpot/Salesforce/kintoneを直接操作する

GTMエンジニアリングMCP

Model Context Protocol(MCP)を使ってClaude CodeからCRMを直接操作するMCPサーバーを構築する。HubSpot、Salesforce、kintoneの3つのCRMに対応するMCPサーバーの設計と実装。

$ cat post.content | render --format=markdown

MCPサーバーでCRMを統合する -- Claude CodeからHubSpot/Salesforce/kintoneを直接操作する

Phase 3: MCPでGTMエンジニアリングの操作基盤を作る

ここまでの記事で、GTMエンジニアリングの4層モデル(技術スタック全体像)、Claude APIによる営業パイプライン自動化(AI処理層の実装)、CLAUDE.mdによる営業知識のコード化(Agent Teamとの連携)を扱ってきた。

Phase 1は概念定義、Phase 2は実装。Phase 3はこれらを統合するインターフェース層に踏み込む。その第一歩がMCPサーバーによるCRM統合だ。

これまでの実装では、CRMへのアクセスはHono APIサーバー経由のHTTPリクエストだった。つまり「サーバーを立てて、エンドポイントを叩く」モデル。動くし実用的だが、GTMエンジニアの日常業務フローには摩擦がある。

エンジニアが開発環境(Claude Code)で作業している最中に「今月のパイプラインどうなってる?」「この企業の商談ステージを更新したい」と思ったとき、わざわざブラウザでCRMを開くかAPIを叩くスクリプトを書く必要がある。

MCPサーバーを使えば、Claude Codeの中から自然言語でCRMを操作できる。開発環境と営業データが直結する。

MCP(Model Context Protocol)とは何か

MCPはAnthropicが策定したプロトコルで、AIモデル(Claude)と外部ツール・データソースを接続するための標準規格だ。

通常はコード補完のためのコンテキスト提供や、ドキュメント参照に使われる。GitHubリポジトリの内容を読み込んだり、Slackのメッセージを検索したり。

GTMエンジニアリングの文脈では、これを営業データアクセスに転用する。

┌──────────────┐      ┌───────────────┐      ┌─────────────────┐
│  Claude Code │◄────►│  MCP Server   │◄────►│    CRM API      │
│  (ユーザー)   │ MCP  │  (TypeScript) │ HTTP │  ├── HubSpot    │
│              │      │               │      │  ├── Salesforce  │
│  「今月の     │      │  Tools:       │      │  └── kintone    │
│   パイプライン│      │  - list-deals │      │                 │
│   を教えて」  │      │  - get-contact│      │                 │
│              │      │  - create-deal│      │                 │
└──────────────┘      └───────────────┘      └─────────────────┘

MCPサーバーはツールリソースの2つの概念を持つ。

概念役割CRM統合での使い方
ツール(Tools)アクションを実行する。引数を受け取り結果を返すlist-deals, create-deal, update-stage
リソース(Resources)静的・半静的なデータを公開するpipeline-summary, recent-deals, icp-definition

ツールはユーザーの指示に応じてClaude Codeが呼び出す。リソースはClaude Codeが必要に応じて参照するコンテキスト情報だ。

MCPサーバーの共通基盤

3つのCRM(HubSpot、Salesforce、kintone)に対応するMCPサーバーの共通部分を先に設計する。

プロジェクト構成

mcp-crm-server/
├── src/
│   ├── index.ts              # エントリポイント
│   ├── server.ts             # MCPサーバー本体
│   ├── types.ts              # 共通型定義
│   ├── providers/
│   │   ├── hubspot.ts        # HubSpot プロバイダー
│   │   ├── salesforce.ts     # Salesforce プロバイダー
│   │   └── kintone.ts        # kintone プロバイダー
│   └── tools/
│       ├── deals.ts          # 商談系ツール
│       ├── contacts.ts       # コンタクト系ツール
│       └── companies.ts      # 企業系ツール
├── package.json
└── tsconfig.json

共通型定義

typescript
// src/types.ts import { z } from 'zod' export const DealSchema = z.object({ id: z.string(), name: z.string(), amount: z.number().nullable(), stage: z.string(), closeDate: z.string().nullable(), companyName: z.string().nullable(), contactName: z.string().nullable(), ownerName: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), probability: z.number().min(0).max(100).nullable(), source: z.enum(['hubspot', 'salesforce', 'kintone']), }) export const ContactSchema = z.object({ id: z.string(), firstName: z.string(), lastName: z.string(), email: z.string().email().nullable(), phone: z.string().nullable(), company: z.string().nullable(), title: z.string().nullable(), lastActivityDate: z.string().nullable(), source: z.enum(['hubspot', 'salesforce', 'kintone']), }) export const CompanySchema = z.object({ id: z.string(), name: z.string(), domain: z.string().nullable(), industry: z.string().nullable(), employeeCount: z.number().nullable(), annualRevenue: z.number().nullable(), source: z.enum(['hubspot', 'salesforce', 'kintone']), }) export const PipelineSummarySchema = z.object({ totalDeals: z.number(), totalValue: z.number(), stageBreakdown: z.array(z.object({ stage: z.string(), count: z.number(), value: z.number(), })), avgDealSize: z.number(), generatedAt: z.string(), }) export type Deal = z.infer<typeof DealSchema> export type Contact = z.infer<typeof ContactSchema> export type Company = z.infer<typeof CompanySchema> export type PipelineSummary = z.infer<typeof PipelineSummarySchema> // CRMプロバイダーのインターフェース export interface CrmProvider { readonly name: 'hubspot' | 'salesforce' | 'kintone' listDeals(options: ListDealsOptions): Promise<Deal[]> getDeal(dealId: string): Promise<Deal> createDeal(input: CreateDealInput): Promise<Deal> updateDealStage(dealId: string, stage: string): Promise<Deal> getContact(contactId: string): Promise<Contact> searchContacts(query: string): Promise<Contact[]> searchCompanies(query: string): Promise<Company[]> getPipelineSummary(): Promise<PipelineSummary> } export interface ListDealsOptions { stage?: string limit?: number offset?: number sortBy?: 'amount' | 'closeDate' | 'createdAt' sortOrder?: 'asc' | 'desc' } export interface CreateDealInput { name: string amount: number stage: string closeDate?: string companyId?: string contactId?: string }

MCPサーバー本体

typescript
// src/server.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { CrmProvider } from './types.js' export function createCrmMcpServer(provider: CrmProvider): McpServer { const server = new McpServer({ name: `crm-${provider.name}`, version: '1.0.0', }) // --- ツール定義 --- server.tool( `${provider.name}-list-deals`, `${provider.name}のパイプラインから商談一覧を取得する`, { stage: z.string().optional().describe('フィルタするステージ名(例: "proposal", "negotiation")'), limit: z.number().int().min(1).max(100).default(20).describe('取得件数(デフォルト20)'), sortBy: z.enum(['amount', 'closeDate', 'createdAt']).default('createdAt').describe('ソートキー'), sortOrder: z.enum(['asc', 'desc']).default('desc').describe('ソート順'), }, async ({ stage, limit, sortBy, sortOrder }) => { try { const deals = await provider.listDeals({ stage, limit, sortBy, sortOrder, }) const summary = deals.length > 0 ? `${deals.length}件の商談を取得。合計金額: ${formatYen(deals.reduce((sum, d) => sum + (d.amount ?? 0), 0))}` : '該当する商談はありません' return { content: [{ type: 'text' as const, text: `${summary}\n\n${JSON.stringify(deals, null, 2)}`, }], } } catch (error) { return { content: [{ type: 'text' as const, text: `商談一覧の取得に失敗: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, } } } ) server.tool( `${provider.name}-get-contact`, `${provider.name}からコンタクトの詳細情報を取得する`, { contactId: z.string().describe('コンタクトID'), }, async ({ contactId }) => { try { const contact = await provider.getContact(contactId) return { content: [{ type: 'text' as const, text: JSON.stringify(contact, null, 2), }], } } catch (error) { return { content: [{ type: 'text' as const, text: `コンタクト取得に失敗: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, } } } ) server.tool( `${provider.name}-create-deal`, `${provider.name}に新規商談を作成する`, { name: z.string().describe('商談名'), amount: z.number().describe('商談金額(円)'), stage: z.string().describe('商談ステージ(例: "qualification", "proposal")'), closeDate: z.string().optional().describe('想定クローズ日(YYYY-MM-DD)'), companyId: z.string().optional().describe('関連する企業ID'), contactId: z.string().optional().describe('関連するコンタクトID'), }, async (input) => { try { const deal = await provider.createDeal(input) return { content: [{ type: 'text' as const, text: `商談を作成しました。\n\nID: ${deal.id}\n名前: ${deal.name}\n金額: ${formatYen(deal.amount ?? 0)}\nステージ: ${deal.stage}`, }], } } catch (error) { return { content: [{ type: 'text' as const, text: `商談作成に失敗: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, } } } ) server.tool( `${provider.name}-update-deal-stage`, `${provider.name}の商談ステージを更新する`, { dealId: z.string().describe('商談ID'), stage: z.string().describe('更新先のステージ名'), }, async ({ dealId, stage }) => { try { const deal = await provider.updateDealStage(dealId, stage) return { content: [{ type: 'text' as const, text: `商談ステージを更新しました。\n\nID: ${deal.id}\n名前: ${deal.name}\n新ステージ: ${deal.stage}`, }], } } catch (error) { return { content: [{ type: 'text' as const, text: `ステージ更新に失敗: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, } } } ) server.tool( `${provider.name}-search-companies`, `${provider.name}で企業を検索する`, { query: z.string().describe('検索クエリ(企業名、ドメイン、業種など)'), }, async ({ query }) => { try { const companies = await provider.searchCompanies(query) return { content: [{ type: 'text' as const, text: `${companies.length}件の企業がヒット\n\n${JSON.stringify(companies, null, 2)}`, }], } } catch (error) { return { content: [{ type: 'text' as const, text: `企業検索に失敗: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, } } } ) // --- リソース定義 --- server.resource( `${provider.name}-pipeline-summary`, `${provider.name}://pipeline/summary`, async (uri) => { const summary = await provider.getPipelineSummary() return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(summary, null, 2), }], } } ) return server } function formatYen(amount: number): string { return `${amount.toLocaleString('ja-JP')}` }

HubSpot MCPサーバーの実装

HubSpotは海外SaaS企業で最も採用されているCRMだ。技術スタックの記事のLayer 4で触れた通り、無料プランでもAPIが充実しており、GTMエンジニアリングの実験場として最適。

HubSpotプロバイダー

typescript
// src/providers/hubspot.ts import type { CrmProvider, Deal, Contact, Company, PipelineSummary, ListDealsOptions, CreateDealInput, } from '../types.js' interface HubSpotConfig { accessToken: string baseUrl?: string } interface HubSpotDealProperties { dealname: string amount: string dealstage: string closedate: string hs_lastmodifieddate: string createdate: string pipeline: string hubspot_owner_id: string } interface HubSpotContactProperties { firstname: string lastname: string email: string phone: string company: string jobtitle: string hs_last_activity_date: string } interface HubSpotCompanyProperties { name: string domain: string industry: string numberofemployees: string annualrevenue: string } interface HubSpotSearchResponse<T> { total: number results: Array<{ id: string properties: T createdAt: string updatedAt: string }> } export function createHubSpotProvider(config: HubSpotConfig): CrmProvider { const baseUrl = config.baseUrl ?? 'https://api.hubapi.com' async function hubspotFetch<T>( path: string, options: RequestInit = {} ): Promise<T> { const response = await fetch(`${baseUrl}${path}`, { ...options, headers: { 'Authorization': `Bearer ${config.accessToken}`, 'Content-Type': 'application/json', ...options.headers, }, }) if (!response.ok) { const errorBody = await response.text() throw new Error( `HubSpot API error: ${response.status} ${response.statusText} - ${errorBody}` ) } return response.json() as Promise<T> } function toDeal(raw: { id: string properties: HubSpotDealProperties createdAt: string updatedAt: string }): Deal { return { id: raw.id, name: raw.properties.dealname, amount: raw.properties.amount ? Number(raw.properties.amount) : null, stage: raw.properties.dealstage, closeDate: raw.properties.closedate ?? null, companyName: null, // アソシエーションから別途取得が必要 contactName: null, ownerName: null, createdAt: raw.createdAt, updatedAt: raw.updatedAt, probability: null, source: 'hubspot', } } function toContact(raw: { id: string properties: HubSpotContactProperties createdAt: string updatedAt: string }): Contact { return { id: raw.id, firstName: raw.properties.firstname ?? '', lastName: raw.properties.lastname ?? '', email: raw.properties.email ?? null, phone: raw.properties.phone ?? null, company: raw.properties.company ?? null, title: raw.properties.jobtitle ?? null, lastActivityDate: raw.properties.hs_last_activity_date ?? null, source: 'hubspot', } } function toCompany(raw: { id: string properties: HubSpotCompanyProperties createdAt: string updatedAt: string }): Company { return { id: raw.id, name: raw.properties.name ?? '', domain: raw.properties.domain ?? null, industry: raw.properties.industry ?? null, employeeCount: raw.properties.numberofemployees ? Number(raw.properties.numberofemployees) : null, annualRevenue: raw.properties.annualrevenue ? Number(raw.properties.annualrevenue) : null, source: 'hubspot', } } return { name: 'hubspot', async listDeals(options: ListDealsOptions): Promise<Deal[]> { const sortMap: Record<string, string> = { amount: 'amount', closeDate: 'closedate', createdAt: 'createdate', } const body: Record<string, unknown> = { properties: [ 'dealname', 'amount', 'dealstage', 'closedate', 'pipeline', 'hubspot_owner_id', 'createdate', 'hs_lastmodifieddate', ], limit: options.limit ?? 20, sorts: [{ propertyName: sortMap[options.sortBy ?? 'createdAt'] ?? 'createdate', direction: options.sortOrder === 'asc' ? 'ASCENDING' : 'DESCENDING', }], } if (options.stage) { body.filterGroups = [{ filters: [{ propertyName: 'dealstage', operator: 'EQ', value: options.stage, }], }] } const data = await hubspotFetch<HubSpotSearchResponse<HubSpotDealProperties>>( '/crm/v3/objects/deals/search', { method: 'POST', body: JSON.stringify(body) } ) return data.results.map(toDeal) }, async getDeal(dealId: string): Promise<Deal> { const data = await hubspotFetch<{ id: string properties: HubSpotDealProperties createdAt: string updatedAt: string }>( `/crm/v3/objects/deals/${dealId}?properties=dealname,amount,dealstage,closedate,pipeline,hubspot_owner_id,createdate,hs_lastmodifieddate` ) return toDeal(data) }, async createDeal(input: CreateDealInput): Promise<Deal> { const data = await hubspotFetch<{ id: string properties: HubSpotDealProperties createdAt: string updatedAt: string }>('/crm/v3/objects/deals', { method: 'POST', body: JSON.stringify({ properties: { dealname: input.name, amount: String(input.amount), dealstage: input.stage, closedate: input.closeDate ?? null, pipeline: 'default', }, }), }) // コンタクト・企業のアソシエーション if (input.contactId) { await hubspotFetch( `/crm/v3/objects/deals/${data.id}/associations/contacts/${input.contactId}/deal_to_contact`, { method: 'PUT' } ) } if (input.companyId) { await hubspotFetch( `/crm/v3/objects/deals/${data.id}/associations/companies/${input.companyId}/deal_to_company`, { method: 'PUT' } ) } return toDeal(data) }, async updateDealStage(dealId: string, stage: string): Promise<Deal> { const data = await hubspotFetch<{ id: string properties: HubSpotDealProperties createdAt: string updatedAt: string }>(`/crm/v3/objects/deals/${dealId}`, { method: 'PATCH', body: JSON.stringify({ properties: { dealstage: stage }, }), }) return toDeal(data) }, async getContact(contactId: string): Promise<Contact> { const data = await hubspotFetch<{ id: string properties: HubSpotContactProperties createdAt: string updatedAt: string }>( `/crm/v3/objects/contacts/${contactId}?properties=firstname,lastname,email,phone,company,jobtitle,hs_last_activity_date` ) return toContact(data) }, async searchContacts(query: string): Promise<Contact[]> { const data = await hubspotFetch<HubSpotSearchResponse<HubSpotContactProperties>>( '/crm/v3/objects/contacts/search', { method: 'POST', body: JSON.stringify({ query, properties: [ 'firstname', 'lastname', 'email', 'phone', 'company', 'jobtitle', 'hs_last_activity_date', ], limit: 20, }), } ) return data.results.map(toContact) }, async searchCompanies(query: string): Promise<Company[]> { const data = await hubspotFetch<HubSpotSearchResponse<HubSpotCompanyProperties>>( '/crm/v3/objects/companies/search', { method: 'POST', body: JSON.stringify({ query, properties: [ 'name', 'domain', 'industry', 'numberofemployees', 'annualrevenue', ], limit: 20, }), } ) return data.results.map(toCompany) }, async getPipelineSummary(): Promise<PipelineSummary> { const allDeals = await hubspotFetch<HubSpotSearchResponse<HubSpotDealProperties>>( '/crm/v3/objects/deals/search', { method: 'POST', body: JSON.stringify({ properties: ['dealname', 'amount', 'dealstage', 'closedate', 'createdate', 'hs_lastmodifieddate', 'pipeline', 'hubspot_owner_id'], limit: 100, sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }], }), } ) const deals = allDeals.results.map(toDeal) const stageMap = new Map<string, { count: number; value: number }>() for (const deal of deals) { const existing = stageMap.get(deal.stage) ?? { count: 0, value: 0 } stageMap.set(deal.stage, { count: existing.count + 1, value: existing.value + (deal.amount ?? 0), }) } const totalValue = deals.reduce((sum, d) => sum + (d.amount ?? 0), 0) return { totalDeals: deals.length, totalValue, stageBreakdown: Array.from(stageMap.entries()).map(([stage, data]) => ({ stage, count: data.count, value: data.value, })), avgDealSize: deals.length > 0 ? totalValue / deals.length : 0, generatedAt: new Date().toISOString(), } }, } }

Salesforce MCPサーバーの実装

SalesforceはエンタープライズCRMのデファクトスタンダード。日本の大手企業では圧倒的なシェアを持つ。HubSpotとは異なり、OAuthの認証フローとSOQLクエリという固有の概念がある。

OAuth認証

Salesforceの認証にはOAuth 2.0のJWT Bearer Flowを使う。サーバー間通信に適した方式で、ユーザーの操作なしにアクセストークンを取得できる。

typescript
// src/providers/salesforce.ts import jwt from 'jsonwebtoken' import type { CrmProvider, Deal, Contact, Company, PipelineSummary, ListDealsOptions, CreateDealInput, } from '../types.js' interface SalesforceConfig { loginUrl: string // https://login.salesforce.com clientId: string // Connected App の Consumer Key username: string // Salesforce ユーザー名 privateKey: string // JWT署名用の秘密鍵(PEM形式) } interface SalesforceAuth { accessToken: string instanceUrl: string expiresAt: number } let cachedAuth: SalesforceAuth | null = null async function authenticate(config: SalesforceConfig): Promise<SalesforceAuth> { if (cachedAuth && cachedAuth.expiresAt > Date.now()) { return cachedAuth } const now = Math.floor(Date.now() / 1000) const token = jwt.sign( { iss: config.clientId, sub: config.username, aud: config.loginUrl, exp: now + 300, // 5分間有効 }, config.privateKey, { algorithm: 'RS256' } ) const params = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token, }) const response = await fetch(`${config.loginUrl}/services/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }) if (!response.ok) { const error = await response.text() throw new Error(`Salesforce auth failed: ${response.status} ${error}`) } const data = await response.json() as { access_token: string instance_url: string } cachedAuth = { accessToken: data.access_token, instanceUrl: data.instance_url, expiresAt: Date.now() + 270_000, // 4分30秒(余裕を持つ) } return cachedAuth }

SOQLクエリをMCPツールとして公開

Salesforceの強みはSOQL(Salesforce Object Query Language)だ。SQLライクなクエリでCRMデータを柔軟に取得できる。これをMCPツールとして公開する。

typescript
// src/providers/salesforce.ts(続き) interface SoqlQueryResult<T> { totalSize: number done: boolean records: T[] } interface SalesforceOpportunity { Id: string Name: string Amount: number | null StageName: string CloseDate: string | null Account?: { Name: string } | null Owner?: { Name: string } | null CreatedDate: string LastModifiedDate: string Probability: number | null } interface SalesforceContact { Id: string FirstName: string LastName: string Email: string | null Phone: string | null Account?: { Name: string } | null Title: string | null LastActivityDate: string | null } interface SalesforceAccount { Id: string Name: string Website: string | null Industry: string | null NumberOfEmployees: number | null AnnualRevenue: number | null } export function createSalesforceProvider(config: SalesforceConfig): CrmProvider { async function soqlQuery<T>(query: string): Promise<SoqlQueryResult<T>> { const auth = await authenticate(config) const encoded = encodeURIComponent(query) const response = await fetch( `${auth.instanceUrl}/services/data/v59.0/query?q=${encoded}`, { headers: { 'Authorization': `Bearer ${auth.accessToken}`, 'Content-Type': 'application/json', }, } ) if (!response.ok) { const error = await response.text() throw new Error(`SOQL query failed: ${response.status} ${error}`) } return response.json() as Promise<SoqlQueryResult<T>> } async function sObjectCreate( objectType: string, data: Record<string, unknown> ): Promise<{ id: string }> { const auth = await authenticate(config) const response = await fetch( `${auth.instanceUrl}/services/data/v59.0/sobjects/${objectType}`, { method: 'POST', headers: { 'Authorization': `Bearer ${auth.accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(data), } ) if (!response.ok) { const error = await response.text() throw new Error(`sObject create failed: ${response.status} ${error}`) } return response.json() as Promise<{ id: string }> } async function sObjectUpdate( objectType: string, id: string, data: Record<string, unknown> ): Promise<void> { const auth = await authenticate(config) const response = await fetch( `${auth.instanceUrl}/services/data/v59.0/sobjects/${objectType}/${id}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${auth.accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(data), } ) if (!response.ok) { const error = await response.text() throw new Error(`sObject update failed: ${response.status} ${error}`) } } function toDeal(opp: SalesforceOpportunity): Deal { return { id: opp.Id, name: opp.Name, amount: opp.Amount, stage: opp.StageName, closeDate: opp.CloseDate, companyName: opp.Account?.Name ?? null, contactName: null, ownerName: opp.Owner?.Name ?? null, createdAt: opp.CreatedDate, updatedAt: opp.LastModifiedDate, probability: opp.Probability, source: 'salesforce', } } function toContact(c: SalesforceContact): Contact { return { id: c.Id, firstName: c.FirstName, lastName: c.LastName, email: c.Email, phone: c.Phone, company: c.Account?.Name ?? null, title: c.Title, lastActivityDate: c.LastActivityDate, source: 'salesforce', } } function toCompany(a: SalesforceAccount): Company { return { id: a.Id, name: a.Name, domain: a.Website, industry: a.Industry, employeeCount: a.NumberOfEmployees, annualRevenue: a.AnnualRevenue, source: 'salesforce', } } return { name: 'salesforce', async listDeals(options: ListDealsOptions): Promise<Deal[]> { const sortFieldMap: Record<string, string> = { amount: 'Amount', closeDate: 'CloseDate', createdAt: 'CreatedDate', } const sortField = sortFieldMap[options.sortBy ?? 'createdAt'] ?? 'CreatedDate' const sortDir = options.sortOrder === 'asc' ? 'ASC' : 'DESC' const stageFilter = options.stage ? `WHERE StageName = '${options.stage}'` : '' const query = ` SELECT Id, Name, Amount, StageName, CloseDate, Account.Name, Owner.Name, CreatedDate, LastModifiedDate, Probability FROM Opportunity ${stageFilter} ORDER BY ${sortField} ${sortDir} LIMIT ${options.limit ?? 20} ` const result = await soqlQuery<SalesforceOpportunity>(query) return result.records.map(toDeal) }, async getDeal(dealId: string): Promise<Deal> { const query = ` SELECT Id, Name, Amount, StageName, CloseDate, Account.Name, Owner.Name, CreatedDate, LastModifiedDate, Probability FROM Opportunity WHERE Id = '${dealId}' LIMIT 1 ` const result = await soqlQuery<SalesforceOpportunity>(query) if (result.records.length === 0) { throw new Error(`Opportunity not found: ${dealId}`) } return toDeal(result.records[0]) }, async createDeal(input: CreateDealInput): Promise<Deal> { const { id } = await sObjectCreate('Opportunity', { Name: input.name, Amount: input.amount, StageName: input.stage, CloseDate: input.closeDate ?? new Date(Date.now() + 90 * 86400000).toISOString().split('T')[0], AccountId: input.companyId ?? null, }) return this.getDeal(id) }, async updateDealStage(dealId: string, stage: string): Promise<Deal> { await sObjectUpdate('Opportunity', dealId, { StageName: stage }) return this.getDeal(dealId) }, async getContact(contactId: string): Promise<Contact> { const query = ` SELECT Id, FirstName, LastName, Email, Phone, Account.Name, Title, LastActivityDate FROM Contact WHERE Id = '${contactId}' LIMIT 1 ` const result = await soqlQuery<SalesforceContact>(query) if (result.records.length === 0) { throw new Error(`Contact not found: ${contactId}`) } return toContact(result.records[0]) }, async searchContacts(query: string): Promise<Contact[]> { const soql = ` SELECT Id, FirstName, LastName, Email, Phone, Account.Name, Title, LastActivityDate FROM Contact WHERE Name LIKE '%${query}%' OR Email LIKE '%${query}%' OR Account.Name LIKE '%${query}%' LIMIT 20 ` const result = await soqlQuery<SalesforceContact>(soql) return result.records.map(toContact) }, async searchCompanies(query: string): Promise<Company[]> { const soql = ` SELECT Id, Name, Website, Industry, NumberOfEmployees, AnnualRevenue FROM Account WHERE Name LIKE '%${query}%' OR Website LIKE '%${query}%' LIMIT 20 ` const result = await soqlQuery<SalesforceAccount>(soql) return result.records.map(toCompany) }, async getPipelineSummary(): Promise<PipelineSummary> { const query = ` SELECT StageName, COUNT(Id) dealCount, SUM(Amount) totalAmount FROM Opportunity WHERE IsClosed = false GROUP BY StageName ` const result = await soqlQuery<{ StageName: string dealCount: number totalAmount: number }>(query) const stageBreakdown = result.records.map((r) => ({ stage: r.StageName, count: r.dealCount, value: r.totalAmount ?? 0, })) const totalDeals = stageBreakdown.reduce((sum, s) => sum + s.count, 0) const totalValue = stageBreakdown.reduce((sum, s) => sum + s.value, 0) return { totalDeals, totalValue, stageBreakdown, avgDealSize: totalDeals > 0 ? totalValue / totalDeals : 0, generatedAt: new Date().toISOString(), } }, } }

SOQLクエリをそのまま公開せずCrmProviderインターフェースでラップしている点がポイントだ。Claude Codeから「SQLインジェクションまがいのクエリを投げられる」リスクを排除する。ユーザーの自由入力がSOQLに直接注入されないように、ツール側で安全なクエリを組み立てる。

kintone MCPサーバーの実装

kintoneはサイボウズが提供する業務アプリ構築プラットフォームで、日本企業のCRM用途では無視できないシェアを持つ。特に中小企業で「CRM専用ツールではなくkintoneで顧客管理している」ケースが多い。

kintoneの特徴はアプリごとにフィールド構成が異なる点。HubSpotやSalesforceのように「Opportunity」「Contact」といった固定オブジェクトがない。各企業がkintoneアプリでカスタムのフィールドを定義している。

kintoneプロバイダー

typescript
// src/providers/kintone.ts import type { CrmProvider, Deal, Contact, Company, PipelineSummary, ListDealsOptions, CreateDealInput, } from '../types.js' interface KintoneConfig { baseUrl: string // https://{subdomain}.cybozu.com apiToken: string // kintone APIトークン dealAppId: string // 商談管理アプリのID contactAppId: string // 顧客管理アプリのID companyAppId: string // 企業管理アプリのID fieldMapping: KintoneFieldMapping } // kintoneのフィールド名は企業ごとに異なるため、マッピング設定で吸収する interface KintoneFieldMapping { deal: { name: string // 例: "案件名" amount: string // 例: "商談金額" stage: string // 例: "ステージ" closeDate: string // 例: "受注予定日" companyName: string // 例: "企業名" contactName: string // 例: "担当者名" } contact: { firstName: string // 例: "名" lastName: string // 例: "姓" email: string // 例: "メールアドレス" phone: string // 例: "電話番号" company: string // 例: "会社名" title: string // 例: "役職" } company: { name: string // 例: "会社名" domain: string // 例: "ウェブサイト" industry: string // 例: "業種" employeeCount: string // 例: "従業員数" } } interface KintoneRecord { $id: { type: string; value: string } $revision: { type: string; value: string } [fieldCode: string]: { type: string; value: string } } interface KintoneGetRecordsResponse { records: KintoneRecord[] totalCount: string } export function createKintoneProvider(config: KintoneConfig): CrmProvider { const { fieldMapping } = config async function kintoneRequest<T>( path: string, options: RequestInit = {} ): Promise<T> { const response = await fetch(`${config.baseUrl}/k/v1${path}`, { ...options, headers: { 'X-Cybozu-API-Token': config.apiToken, 'Content-Type': 'application/json', ...options.headers, }, }) if (!response.ok) { const error = await response.text() throw new Error( `kintone API error: ${response.status} - ${error}` ) } return response.json() as Promise<T> } function getFieldValue(record: KintoneRecord, fieldCode: string): string { return record[fieldCode]?.value ?? '' } function recordToDeal(record: KintoneRecord): Deal { const fm = fieldMapping.deal return { id: record.$id.value, name: getFieldValue(record, fm.name), amount: getFieldValue(record, fm.amount) ? Number(getFieldValue(record, fm.amount)) : null, stage: getFieldValue(record, fm.stage), closeDate: getFieldValue(record, fm.closeDate) || null, companyName: getFieldValue(record, fm.companyName) || null, contactName: getFieldValue(record, fm.contactName) || null, ownerName: null, createdAt: getFieldValue(record, '作成日時'), updatedAt: getFieldValue(record, '更新日時'), probability: null, source: 'kintone', } } function recordToContact(record: KintoneRecord): Contact { const fm = fieldMapping.contact return { id: record.$id.value, firstName: getFieldValue(record, fm.firstName), lastName: getFieldValue(record, fm.lastName), email: getFieldValue(record, fm.email) || null, phone: getFieldValue(record, fm.phone) || null, company: getFieldValue(record, fm.company) || null, title: getFieldValue(record, fm.title) || null, lastActivityDate: null, source: 'kintone', } } function recordToCompany(record: KintoneRecord): Company { const fm = fieldMapping.company return { id: record.$id.value, name: getFieldValue(record, fm.name), domain: getFieldValue(record, fm.domain) || null, industry: getFieldValue(record, fm.industry) || null, employeeCount: getFieldValue(record, fm.employeeCount) ? Number(getFieldValue(record, fm.employeeCount)) : null, annualRevenue: null, source: 'kintone', } } return { name: 'kintone', async listDeals(options: ListDealsOptions): Promise<Deal[]> { const fm = fieldMapping.deal const sortFieldMap: Record<string, string> = { amount: fm.amount, closeDate: fm.closeDate, createdAt: '作成日時', } const sortField = sortFieldMap[options.sortBy ?? 'createdAt'] ?? '作成日時' const sortDir = options.sortOrder === 'asc' ? 'asc' : 'desc' const query = options.stage ? `${fm.stage} = "${options.stage}" order by ${sortField} ${sortDir} limit ${options.limit ?? 20}` : `order by ${sortField} ${sortDir} limit ${options.limit ?? 20}` const data = await kintoneRequest<KintoneGetRecordsResponse>( '/records.json', { method: 'GET', headers: { 'X-Cybozu-API-Token': config.apiToken, }, } ) // GETパラメータでクエリを渡す場合 const params = new URLSearchParams({ app: config.dealAppId, query, }) const result = await kintoneRequest<KintoneGetRecordsResponse>( `/records.json?${params.toString()}` ) return result.records.map(recordToDeal) }, async getDeal(dealId: string): Promise<Deal> { const data = await kintoneRequest<{ record: KintoneRecord }>( `/record.json?app=${config.dealAppId}&id=${dealId}` ) return recordToDeal(data.record) }, async createDeal(input: CreateDealInput): Promise<Deal> { const fm = fieldMapping.deal const data = await kintoneRequest<{ id: string; revision: string }>( '/record.json', { method: 'POST', body: JSON.stringify({ app: config.dealAppId, record: { [fm.name]: { value: input.name }, [fm.amount]: { value: String(input.amount) }, [fm.stage]: { value: input.stage }, [fm.closeDate]: { value: input.closeDate ?? '' }, }, }), } ) return this.getDeal(data.id) }, async updateDealStage(dealId: string, stage: string): Promise<Deal> { const fm = fieldMapping.deal await kintoneRequest( '/record.json', { method: 'PUT', body: JSON.stringify({ app: config.dealAppId, id: dealId, record: { [fm.stage]: { value: stage }, }, }), } ) return this.getDeal(dealId) }, async getContact(contactId: string): Promise<Contact> { const data = await kintoneRequest<{ record: KintoneRecord }>( `/record.json?app=${config.contactAppId}&id=${contactId}` ) return recordToContact(data.record) }, async searchContacts(query: string): Promise<Contact[]> { const fm = fieldMapping.contact const kintoneQuery = `${fm.lastName} like "${query}" or ${fm.email} like "${query}" or ${fm.company} like "${query}" limit 20` const params = new URLSearchParams({ app: config.contactAppId, query: kintoneQuery, }) const result = await kintoneRequest<KintoneGetRecordsResponse>( `/records.json?${params.toString()}` ) return result.records.map(recordToContact) }, async searchCompanies(query: string): Promise<Company[]> { const fm = fieldMapping.company const kintoneQuery = `${fm.name} like "${query}" limit 20` const params = new URLSearchParams({ app: config.companyAppId, query: kintoneQuery, }) const result = await kintoneRequest<KintoneGetRecordsResponse>( `/records.json?${params.toString()}` ) return result.records.map(recordToCompany) }, async getPipelineSummary(): Promise<PipelineSummary> { const fm = fieldMapping.deal const params = new URLSearchParams({ app: config.dealAppId, query: 'limit 500', }) const result = await kintoneRequest<KintoneGetRecordsResponse>( `/records.json?${params.toString()}` ) const deals = result.records.map(recordToDeal) const stageMap = new Map<string, { count: number; value: number }>() for (const deal of deals) { const existing = stageMap.get(deal.stage) ?? { count: 0, value: 0 } stageMap.set(deal.stage, { count: existing.count + 1, value: existing.value + (deal.amount ?? 0), }) } const totalValue = deals.reduce((sum, d) => sum + (d.amount ?? 0), 0) return { totalDeals: deals.length, totalValue, stageBreakdown: Array.from(stageMap.entries()).map(([stage, data]) => ({ stage, count: data.count, value: data.value, })), avgDealSize: deals.length > 0 ? totalValue / deals.length : 0, generatedAt: new Date().toISOString(), } }, } }

kintone実装の最大のポイントはfieldMappingだ。HubSpotにはdealname、SalesforceにはNameというフィールド名が固定で存在するが、kintoneでは「案件名」「商談名」「プロジェクト名」と企業ごとに異なる。このマッピング設定を外出しにすることで、各社のkintone環境に対応できる。

エントリポイントと起動

typescript
// src/index.ts import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { createCrmMcpServer } from './server.js' import { createHubSpotProvider } from './providers/hubspot.js' import { createSalesforceProvider } from './providers/salesforce.js' import { createKintoneProvider } from './providers/kintone.js' const crmType = process.env.CRM_TYPE ?? 'hubspot' function createProvider() { switch (crmType) { case 'hubspot': return createHubSpotProvider({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN ?? '', }) case 'salesforce': return createSalesforceProvider({ loginUrl: process.env.SF_LOGIN_URL ?? 'https://login.salesforce.com', clientId: process.env.SF_CLIENT_ID ?? '', username: process.env.SF_USERNAME ?? '', privateKey: process.env.SF_PRIVATE_KEY ?? '', }) case 'kintone': return createKintoneProvider({ baseUrl: process.env.KINTONE_BASE_URL ?? '', apiToken: process.env.KINTONE_API_TOKEN ?? '', dealAppId: process.env.KINTONE_DEAL_APP_ID ?? '', contactAppId: process.env.KINTONE_CONTACT_APP_ID ?? '', companyAppId: process.env.KINTONE_COMPANY_APP_ID ?? '', fieldMapping: JSON.parse( process.env.KINTONE_FIELD_MAPPING ?? '{}' ), }) default: throw new Error(`Unsupported CRM type: ${crmType}`) } } async function main() { const provider = createProvider() const server = createCrmMcpServer(provider) const transport = new StdioServerTransport() await server.connect(transport) } main().catch((error) => { console.error('MCP Server failed to start:', error) process.exit(1) })

MCPサーバーの設定方法

Claude Code(.claude.json)での設定

Claude Codeでこの MCPサーバーを使うには、プロジェクトの.claude.json(またはグローバルの~/.claude.json)にMCPサーバーの設定を追加する。

json
{ "mcpServers": { "crm-hubspot": { "command": "npx", "args": ["tsx", "/path/to/mcp-crm-server/src/index.ts"], "env": { "CRM_TYPE": "hubspot", "HUBSPOT_ACCESS_TOKEN": "${HUBSPOT_ACCESS_TOKEN}" } } } }

複数CRMの同時接続

HubSpotとSalesforceを同時に使う場合。マーケティングチームがHubSpot、エンタープライズ営業チームがSalesforceという組織は少なくない。

json
{ "mcpServers": { "crm-hubspot": { "command": "npx", "args": ["tsx", "/path/to/mcp-crm-server/src/index.ts"], "env": { "CRM_TYPE": "hubspot", "HUBSPOT_ACCESS_TOKEN": "${HUBSPOT_ACCESS_TOKEN}" } }, "crm-salesforce": { "command": "npx", "args": ["tsx", "/path/to/mcp-crm-server/src/index.ts"], "env": { "CRM_TYPE": "salesforce", "SF_LOGIN_URL": "https://login.salesforce.com", "SF_CLIENT_ID": "${SF_CLIENT_ID}", "SF_USERNAME": "${SF_USERNAME}", "SF_PRIVATE_KEY": "${SF_PRIVATE_KEY}" } } } }

Claude Codeからはhubspot-list-dealssalesforce-list-dealsが別のツールとして見える。「HubSpotとSalesforce両方のパイプラインを比較して」と言えば、両方のツールが呼ばれて統合レポートが返ってくる。

kintoneの場合(フィールドマッピング付き)

json
{ "mcpServers": { "crm-kintone": { "command": "npx", "args": ["tsx", "/path/to/mcp-crm-server/src/index.ts"], "env": { "CRM_TYPE": "kintone", "KINTONE_BASE_URL": "https://your-subdomain.cybozu.com", "KINTONE_API_TOKEN": "${KINTONE_API_TOKEN}", "KINTONE_DEAL_APP_ID": "12", "KINTONE_CONTACT_APP_ID": "13", "KINTONE_COMPANY_APP_ID": "14", "KINTONE_FIELD_MAPPING": "{\"deal\":{\"name\":\"案件名\",\"amount\":\"商談金額\",\"stage\":\"ステージ\",\"closeDate\":\"受注予定日\",\"companyName\":\"企業名\",\"contactName\":\"担当者名\"},\"contact\":{\"firstName\":\"名\",\"lastName\":\"姓\",\"email\":\"メールアドレス\",\"phone\":\"電話番号\",\"company\":\"会社名\",\"title\":\"役職\"},\"company\":{\"name\":\"会社名\",\"domain\":\"ウェブサイト\",\"industry\":\"業種\",\"employeeCount\":\"従業員数\"}}" } } } }

活用シナリオ

MCPサーバーが稼働している状態で、Claude Codeの中からCRMデータにどうアクセスするか。具体的なシナリオを4つ示す。

シナリオ1: パイプライン状況のリアルタイム確認

Claude Codeで開発作業中に、営業チームから「今月のパイプラインどうなってる?」とSlackが来た。ブラウザでHubSpotを開く代わりに、Claude Codeの中で確認する。

ユーザー: 今月のパイプライン状況をまとめて

Claude Codeはhubspot-list-dealsツールを呼び出し、リソースのhubspot-pipeline-summaryも参照して回答を構成する。

パイプラインサマリー:
- 総商談数: 47件
- 総金額: 2,340万円
- 平均商談サイズ: 497,872円

ステージ別:
| ステージ       | 件数 | 金額       |
|---------------|------|-----------|
| qualification | 12   | 420万円    |
| proposal      | 8    | 680万円    |
| negotiation   | 5    | 890万円    |
| closed-won    | 15   | 210万円    |
| closed-lost   | 7    | 140万円    |

negotiationステージに890万円が集中しています。
このうち今月クローズ予定のものを確認しますか?

開発環境から一歩も出ずに営業データを把握できる。

シナリオ2: 商談ステージの直接更新

営業担当から「A社の商談がProposalに進んだから更新しておいて」と連絡が来た場合。

ユーザー: A社の商談ステージをproposalに更新して

Claude Codeはhubspot-search-companiesでA社を特定し、関連する商談をhubspot-list-dealsで取得、該当する商談をhubspot-update-deal-stageで更新する。

シナリオ3: Agent Teamと組み合わせた日次パイプラインレポート

前回の記事で構築したAgent Teamの仕組みとMCPサーバーを組み合わせる。CLAUDE.mdにレポート生成のSkillsを定義しておけば、毎朝のパイプラインレポートが自動生成される。

ユーザー: 日次パイプラインレポートを生成して、#sales-reportチャンネルに投稿して

Agent Teamの処理フロー:

[Step 1] MCPツール hubspot-list-deals で全商談取得
    ↓
[Step 2] MCPリソース hubspot-pipeline-summary でサマリー取得
    ↓
[Step 3] CLAUDE.mdのICP定義に基づき注目すべき商談を抽出
    ↓
[Step 4] Slack MCPサーバー経由で #sales-report に投稿

Step 1-2とStep 4で異なるMCPサーバーが連携する点がMCPの強みだ。CRM MCPサーバーからデータを取得し、Slack MCPサーバーで通知する。Claude Codeがオーケストレーターとなり、複数のMCPサーバーを束ねる。

シナリオ4: リード情報から提案書自動生成パイプラインへ

AI処理層の記事で構築したリードスコアリング→リサーチ→メッセージ生成のパイプラインの入力を、MCPサーバーから直接取得するシナリオ。

ユーザー: HubSpotの新規リードを取得して、スコア80以上のリードに対して提案書ドラフトを生成して
[Step 1] hubspot-list-deals で stage=qualification のリードを取得
    ↓
[Step 2] 各リードの企業情報を hubspot-search-companies で補完
    ↓
[Step 3] CLAUDE.mdのICP定義でスコアリング(Agent Team内のAI処理)
    ↓
[Step 4] スコア80以上のリードに対し、リサーチ→提案書ドラフト生成
    ↓
[Step 5] 生成した提案書をプロジェクトの proposals/ に保存

従来は「HubSpot開く → リード一覧をCSVエクスポート → スクリプトに流す」という手作業が入っていた。MCPサーバーを介することで、この流れが完全に自動化される。

セキュリティ考慮事項

CRMには顧客の個人情報と商談の機密情報が入っている。MCPサーバーでCRMを公開する際のセキュリティ設計は妥協できない。

APIキーの管理

# .env(gitignore対象)
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
SF_CLIENT_ID=3MVG9xxxxxxxx
SF_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
KINTONE_API_TOKEN=xxxxxxxx

環境変数で管理し、.envファイルは.gitignoreに必ず含める。Claude Code側の設定では${HUBSPOT_ACCESS_TOKEN}のように環境変数参照を使い、トークンの値を設定ファイルに直書きしない。

読み取り専用モードと書き込みモードの分離

チームで使う場合、全員に書き込み権限を渡すのは危険だ。MCPサーバー側で読み取り専用モードを実装する。

typescript
// src/server.ts に追加 const readOnly = process.env.CRM_READ_ONLY === 'true' // 書き込み系ツールの登録を条件分岐 if (!readOnly) { server.tool(`${provider.name}-create-deal`, /* ... */) server.tool(`${provider.name}-update-deal-stage`, /* ... */) }

設定側で分離する:

json
{ "mcpServers": { "crm-readonly": { "command": "npx", "args": ["tsx", "/path/to/mcp-crm-server/src/index.ts"], "env": { "CRM_TYPE": "hubspot", "HUBSPOT_ACCESS_TOKEN": "${HUBSPOT_READ_TOKEN}", "CRM_READ_ONLY": "true" } }, "crm-write": { "command": "npx", "args": ["tsx", "/path/to/mcp-crm-server/src/index.ts"], "env": { "CRM_TYPE": "hubspot", "HUBSPOT_ACCESS_TOKEN": "${HUBSPOT_WRITE_TOKEN}", "CRM_READ_ONLY": "false" } } } }

APIレート制限の遵守

各CRMにはAPIレート制限がある。

CRMレート制限対策
HubSpot100リクエスト/10秒(Private App)リクエスト間隔の制御
Salesforce100,000リクエスト/24時間(Enterprise)日次上限の監視
kintone10,000リクエスト/日(Standard)日次上限の監視 + キャッシュ

MCPサーバー側にレートリミッターを入れる:

typescript
// src/rate-limiter.ts interface RateLimiterConfig { maxRequests: number windowMs: number } export function createRateLimiter(config: RateLimiterConfig) { const timestamps: number[] = [] return async function waitForSlot(): Promise<void> { const now = Date.now() const windowStart = now - config.windowMs // ウィンドウ外のタイムスタンプを除去 while (timestamps.length > 0 && timestamps[0] < windowStart) { timestamps.shift() } if (timestamps.length >= config.maxRequests) { const waitTime = timestamps[0] + config.windowMs - now await new Promise((resolve) => setTimeout(resolve, waitTime)) } timestamps.push(Date.now()) } }

個人情報保護

MCPサーバーのレスポンスにはコンタクトの氏名、メールアドレス、電話番号が含まれる。Claude Codeのログにこれらが残る可能性がある。

対策として:

  • MCPサーバーのレスポンスログを無効化する
  • 本番環境ではメールアドレスの一部をマスクする(t***@example.com
  • Claude Codeのセッション履歴のクリーンアップルールを定める

3つのCRMを実装して分かったこと

実装を通して見えた各CRMの特性をまとめる。

観点HubSpotSalesforcekintone
API設計RESTful。直感的REST + SOQL。柔軟だが学習コスト高REST。シンプルだがクエリ機能が限定的
認証Private App Token(簡単)OAuth JWT Bearer(複雑)APIトークン(簡単)
データモデル固定オブジェクト固定 + カスタムオブジェクト完全カスタム(アプリ単位)
日本語対応フィールド名は英語固定日本語ラベル可完全日本語対応
無料プランあり(API制限あり)なしあり(5ユーザーまで)
MCPサーバー実装の難易度中(フィールドマッピングが必要)
GTMエンジニアリングとの相性高(海外ツールとの連携が豊富)高(エンタープライズ向け)中(日本中小企業向け)

GTMエンジニアリングの文脈では、HubSpotが第一選択肢になることが多い。無料プランでAPI実験ができる。Clayやn8nとのネイティブ連携が充実している。そしてHubSpotのAPI設計がRESTfulで扱いやすい。

一方、日本のエンタープライズ案件ではSalesforceが避けられない。SOQLの柔軟性はMCPツールとしても強力で、「過去3ヶ月で金額500万円以上のnegotiationステージの商談」のような複合条件を一発で取得できる。

kintoneは「CRM専用ツールではないがCRMとして使っている」日本企業への対応として必要。フィールドマッピングの設計を入れたことで、各社のカスタマイズに柔軟に対応できる。

次にやること

この記事で構築したMCPサーバーは、技術スタックの4層モデルにおいてLayer 4(CRM出力層)とLayer 2(AI処理層)を直結するバイパスだ。これまではLayer 3(オーケストレーション層)を経由してCRMにアクセスしていたが、MCPサーバーによってClaude Codeが直接CRMを操作できる。

CLAUDE.mdによる営業知識のコード化と組み合わせれば、Agent TeamがCRMデータを読み取り→分析→更新するループを完全に自動化できる。AIパイプラインの入力をCRMから直接取得し、処理結果をCRMに直接書き戻す。中間のHTTPサーバーやn8nワークフローが不要になる。

次の記事ではTypeScriptモノレポでBtoB SaaSのGTMインフラを統合管理する方法を扱う。MCPサーバー、Hono APIサーバー、Claude Code Skillsを1つのモノレポに収め、チーム全体で共有・バージョン管理する設計パターンを示す。


参考資料

$ echo $TAGS
#MCP#Claude Code#HubSpot#Salesforce#kintone#CRM統合#TypeScript