Productivity & Project Management Integrations · 2026-08-07 · Productivity arc

Productivity & Project Management Integrations for MCP Servers: Linear, Airtable, Google Sheets, Confluence, Asana

Five productivity APIs — Linear, Airtable, Google Sheets, Confluence, and Asana — appear in MCP servers for the same structural reason: knowledge workers spend hours each day moving information between these tools, and an MCP server that can read and write them programmatically turns an AI agent into a workspace operator rather than just a Q&A assistant. The five APIs share enough surface area to expose four structural patterns that cut across all of them — and enough surface-level divergence to produce subtly wrong implementations that compile, deploy, and fail only at runtime under real workspace data. The first pattern is stable identifier storage: every one of these APIs distinguishes between opaque stable identifiers (table IDs, GIDs, space keys, spreadsheet IDs) and human-readable names (table names, task names, space names, sheet tab names) that are mutable — a user renames a table, moves a task, or changes a section name and a tool that stored the name rather than the ID stops working silently; Airtable table names break silently on rename while table IDs (tbl...) are permanent, Linear issue identifiers (ENG-142) are display strings while numeric GIDs are the API currency, Asana task names are text while GIDs are stable, Confluence space keys are URL-stable while space IDs differ between v1 and v2 API endpoints, and Google Sheets spreadsheet IDs are stable in the URL while sheet tab names change every time someone reorganizes the workbook. The second pattern is sparse fieldset design: all five APIs default to returning minimal payloads that omit most of the fields a working MCP tool needs — Asana returns only {gid, name} without opt_fields, Confluence returns page metadata without body unless you pass ?expand=body.storage, Airtable returns linked record fields as arrays of record IDs requiring a second API call to expand, Linear's typed SDK omits fields and mutations available only via linear.rawRequest(), and Google Sheets routes structural and data operations to two different batchUpdate endpoints that silently reject requests meant for the other. The third pattern is HMAC webhook verification: Linear and Asana both use HMAC-SHA256 to sign webhook payloads but with different header names, different signing inputs, and — in Asana's case — a two-phase handshake where the first delivery requires echoing a secret header rather than verifying a signature; failing to handle the handshake causes Asana to mark the webhook as undeliverable before a single real event is received. The fourth pattern is write-safe update operations: Confluence requires an incremented version.number on every page update and returns 409 on concurrent edit races, Google Sheets interprets cell values as formulas or typed data depending on valueInputOption and requires insertDataOption: 'INSERT_ROWS' for append-safe writes, Airtable's typecast: true silently creates new select field options when a string doesn't match — a data integrity hazard with LLM-generated input, and Asana's due_on and due_at fields are mutually exclusive with silent overwrite behavior. This post synthesizes all four patterns with annotated code, covers 18 failure modes with root cause and fix, and provides a decision table for each integration choice.

TL;DR

Four patterns, five tools. (1) Stable identifier storage: Always store the opaque ID, never the human-readable name. Airtable — table ID (tbl...), field ID (fld...), base ID (app...) from the API meta endpoint; never the display name. Linear — numeric GID from issue.id, not the identifier (ENG-142) which is display-only; team workflow states via team.states() before issue creation (no global state IDs). Asanagid on every resource; workspace GID from the teams endpoint, not the workspace name. Confluence — space key (URL slug, stable across v1/v2) for CQL and page paths; page ID (numeric) for direct page updates; space ID (numeric, different in v1 vs v2) only when an endpoint requires it. Google Sheets — spreadsheet ID from the URL between /d/ and /edit; sheet (tab) ID (sheetId numeric) for structural operations, sheet name only for value reads. (2) Sparse fieldsets: Asana — always pass opt_fields; minimum useful set for task operations: gid,name,notes,due_on,due_at,assignee.gid,assignee.name,memberships.project.gid,memberships.section.gid,memberships.section.name. Confluence — append ?expand=body.storage,version,ancestors to GET page requests; body.storage is the editable XHTML body, version is required for safe updates. Airtable — linked record fields are ID arrays; call GET /v0/{baseId}/{tableId}?filterByFormula=OR(RECORD_ID()="rec...",...) with a batch formula to expand in one request. (3) HMAC webhook verification: Linearcrypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex'); compare to req.headers['linear-signature']; always use the raw body buffer, never a re-serialized parsed JSON. Asana — on the first POST for a new webhook, if X-Hook-Secret header is present, reply 200 with that exact value in the response X-Hook-Secret header and store the secret; on all subsequent deliveries, verify X-Hook-Signature with HMAC-SHA256 of the raw body against the stored secret. (4) Write-safety: Confluence — GET the page first to read version.number, then PUT with version: { number: currentVersion + 1 }; handle 409 with retry-after-re-read. Google Sheets — always valueInputOption: 'RAW' for LLM-generated strings; use spreadsheets.values.append with insertDataOption: 'INSERT_ROWS' for safe row addition (never overwrite). Airtable — set typecast: false (the default) when writing LLM-generated content to Select fields; validate against the field schema before writing. Asana — include only one of due_on or due_at per update; setting both or overwriting the wrong one clears the other silently.

Pattern 1 — Stable Identifier Storage: The Cross-Tool Storage Contract

Every productivity API in this arc makes a structural distinction between stable opaque identifiers and mutable human-readable names. The distinction matters because workspace administrators rename things constantly: tables get renamed when a project changes scope, Confluence spaces get restructured, Linear workflow states are renamed to match new team conventions, and Google Sheets tabs get reorganized every quarter. An MCP tool that resolves a name to an ID at call time (reading from a parameter the calling agent supplies) is safe — but an MCP tool that stores a name in its own state, caches a name it received from a previous API call, or documents that its table_name parameter accepts a name rather than an ID will break silently under normal workspace administration.

The failure mode is always the same: the rename happens, the tool still runs, the API returns a 404 or a wrong-table result, and the error is attributed to a bug in the agent rather than to a naming assumption baked into the tool's interface. Fixing this after the fact requires tracking down every place a name was stored and replacing it with an ID, which is much harder than starting with IDs.

Airtable: table IDs vs table names

Airtable table names appear prominently in the Airtable UI, in documentation examples, and in the API URL path — the endpoint is GET /v0/{baseId}/{tableIdOrName}/, and passing a table name works at first. The problem is that table names are URL-encoded display strings that change when a user renames the table, while table IDs (tbl...) are permanent opaque strings that never change for the lifetime of the table. The same pattern applies to field IDs (fld...) vs field names and base IDs (app...) vs base names.

import Airtable from 'airtable';
import { z } from 'zod';

// Retrieve table and field IDs from the Airtable meta API — call once at startup
// and cache. Never store names in tool parameters.
async function getBaseSchema(baseId: string, apiKey: string) {
  const res = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  const data = await res.json();
  return data.tables as Array<{ id: string; name: string; fields: Array<{ id: string; name: string; type: string }> }>;
}

// MCP tool: search Airtable records by a field value
// Parameter is table_id (tbl...), not table_name — document this explicitly
server.tool(
  'search_airtable_records',
  {
    base_id:    z.string().describe('Airtable base ID — starts with "app"'),
    table_id:   z.string().describe('Airtable table ID — starts with "tbl". Use the table ID, not the table name.'),
    field_id:   z.string().describe('Field ID to filter on — starts with "fld"'),
    value:      z.string().describe('Value to match'),
  },
  async ({ base_id, table_id, field_id, value }) => {
    const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(base_id);

    // filterByFormula uses {Field Name} syntax — resolve field name from ID
    // so the formula is correct even if the field name contains special characters
    const schema = await getBaseSchema(base_id, process.env.AIRTABLE_API_KEY!);
    const table  = schema.find(t => t.id === table_id);
    if (!table) return { content: [{ type: 'text', text: `Table ${table_id} not found in base ${base_id}` }], isError: true };

    const field = table.fields.find(f => f.id === field_id);
    if (!field) return { content: [{ type: 'text', text: `Field ${field_id} not found in table ${table_id}` }], isError: true };

    // Curly-brace syntax is required when the field name contains spaces
    const formula  = `{${field.name}} = "${value.replace(/"/g, '\\"')}"`;
    const records  = await base(table_id).select({ filterByFormula: formula }).all();

    return {
      content: [{ type: 'text', text: JSON.stringify(records.map(r => ({ id: r.id, fields: r.fields })), null, 2) }],
    };
  }
);

Linear: GIDs vs identifiers vs team-scoped workflow states

Linear exposes three identifier types that are easy to confuse: the numeric GID (issue.id — a UUID used for API calls), the human-readable identifier (issue.identifier — ENG-142, which is display-only and changes if a team key is renamed), and workflow state IDs which are scoped to a specific team and have no global equivalents. The most common mistake is using the display identifier (ENG-142) in API mutations — it works in some GraphQL queries but not in mutations that expect a UUID, and it breaks when a team changes their prefix from ENG to PLATFORM. Workflow state IDs are the second trap: there are no global state IDs like "In Progress" — each team has its own states, and the state IDs for Team A's "In Progress" are different from Team B's "In Progress". Before creating or updating an issue, always query team.states() to resolve the state name to the team-specific state ID.

import { LinearClient } from '@linear/sdk';

const linearClient = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });

// Resolve a state name to the team-specific state ID — call before issue creation
async function resolveStateId(teamId: string, stateName: string): Promise<string | null> {
  const team   = await linearClient.team(teamId);
  const states = await team.states();
  const match  = states.nodes.find(s => s.name.toLowerCase() === stateName.toLowerCase());
  return match?.id ?? null;
}

server.tool(
  'create_linear_issue',
  {
    team_id:     z.string().describe('Linear team ID (UUID) — not the team key like "ENG"'),
    title:       z.string().min(1).max(500),
    description: z.string().optional(),
    state_name:  z.string().default('Todo').describe('Workflow state name — resolved to team-specific ID'),
    label_ids:   z.array(z.string()).optional().describe('Label IDs (UUID) — not label names, which are mutable'),
  },
  async ({ team_id, title, description, state_name, label_ids }) => {
    const stateId = await resolveStateId(team_id, state_name);
    if (!stateId) {
      // Surface available states so the caller can correct the name
      const team   = await linearClient.team(team_id);
      const states = await team.states();
      const names  = states.nodes.map(s => s.name).join(', ');
      return { content: [{ type: 'text', text: `State "${state_name}" not found. Available states: ${names}` }], isError: true };
    }

    const issue = await linearClient.createIssue({
      teamId:      team_id,
      title,
      description,
      stateId,
      labelIds:    label_ids,
    });

    // Return the GID (UUID), not the identifier — GID is what future API calls need
    const created = await issue.issue;
    return {
      content: [{ type: 'text', text: JSON.stringify({ id: created?.id, identifier: created?.identifier, url: created?.url }) }],
    };
  }
);

Asana and Confluence: GIDs and space keys

Asana's GID is the universal stable identifier — every resource (task, project, section, workspace, user, tag) has a gid that never changes. Tool parameters should always use gid, never a name. Confluence is slightly more complex: the space key (the short alphanumeric string that appears in URLs, like ENG or DOCS) is stable for CQL search and for constructing page paths, while the numeric space ID differs between the v1 and v2 REST APIs — an ID returned from a v1 endpoint cannot be used in a v2 endpoint without checking which version generated it.

Pattern 2 — Sparse Fieldsets: Opt-In Payload Design Across Five APIs

All five APIs return minimal default payloads to reduce bandwidth, but the definition of "minimal" varies enough that a developer moving from one API to another will be surprised at how much is missing from the default response. Asana's default response body for a task contains only {gid, name} — no description, no due date, no assignee, no project, no section. Confluence's default page response contains metadata (ID, title, space, version number) but not the page body — the XHTML storage format that you must send back on update is behind the ?expand=body.storage query parameter. The core problem is that an MCP tool that reads a resource and then updates it must have the full current state before writing — without body.storage, a Confluence update tool has no body to include in the update request and will either error or overwrite the page with an empty body.

Asana opt_fields: the required expansion pattern

Asana's opt_fields mechanism is explicit: you must name every field you want, using dot notation for nested fields. The error mode is silent — without opt_fields, Asana returns a response that validates as a successful task object but contains almost no data, and downstream logic that reads task.due_on or task.assignee.name gets undefined rather than an error. The fix is to declare a standard opt_fields constant for each resource type and always include it.

import { AsanaClient } from 'asana';

const asana = AsanaClient.create().useAccessToken(process.env.ASANA_ACCESS_TOKEN!);

// Standard opt_fields for task reads — include everything needed for display and update
const TASK_OPT_FIELDS = [
  'gid',
  'name',
  'notes',
  'html_notes',
  'due_on',
  'due_at',
  'completed',
  'completed_at',
  'assignee.gid',
  'assignee.name',
  'memberships.project.gid',
  'memberships.project.name',
  'memberships.section.gid',
  'memberships.section.name',
  'tags.gid',
  'tags.name',
  'custom_fields.gid',
  'custom_fields.name',
  'custom_fields.display_value',
  'parent.gid',
  'parent.name',
  'num_subtasks',
  'dependencies.gid',
].join(',');

server.tool(
  'get_asana_task',
  { task_gid: z.string().describe('Asana task GID') },
  async ({ task_gid }) => {
    const response = await asana.tasks.getTask(task_gid, { opt_fields: TASK_OPT_FIELDS });
    return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
  }
);

// Move a task to a new section — Asana has no "status" field
// Workflow stage = project section membership
// Moving = sectionsApi.addTaskForSection(), not updateTask()
server.tool(
  'move_asana_task_to_section',
  {
    task_gid:    z.string().describe('Task GID to move'),
    section_gid: z.string().describe('Target section GID — get from memberships.section.gid'),
  },
  async ({ task_gid, section_gid }) => {
    await asana.sections.addTaskForSection(section_gid, { data: { task: task_gid } });
    return { content: [{ type: 'text', text: `Task ${task_gid} moved to section ${section_gid}` }] };
  }
);

Confluence expand parameters: body.storage for read-modify-write

Confluence's REST API uses an expand query parameter to opt into additional response fields. The most important is body.storage — without it, the response contains the page's title, version, and space metadata but not the page content. For a tool that reads a page and updates it, this means you cannot do a safe update without first requesting the body. The storage format is not Markdown but XHTML-derived XML — the same format you must send back in PUT requests. LLM-generated Markdown content cannot be sent directly to a Confluence PUT; it must be wrapped in Confluence storage XHTML tags.

interface ConfluencePage {
  id:      string;
  title:   string;
  version: { number: number };
  body:    { storage: { value: string; representation: 'storage' } };
}

async function getConfluencePage(pageId: string, token: string, baseUrl: string): Promise<ConfluencePage> {
  const url = `${baseUrl}/rest/api/content/${pageId}?expand=body.storage,version,ancestors`;
  const res = await fetch(url, {
    headers: {
      // Confluence Cloud uses Basic auth with base64(email:api_token)
      // Bearer tokens silently fail with 401 on most Confluence Cloud instances
      Authorization: `Basic ${Buffer.from(`${process.env.CONFLUENCE_EMAIL}:${token}`).toString('base64')}`,
      'Content-Type': 'application/json',
    },
  });
  if (!res.ok) throw new Error(`Confluence GET ${pageId}: ${res.status} ${await res.text()}`);
  return res.json();
}

server.tool(
  'append_to_confluence_page',
  {
    page_id:     z.string().describe('Confluence page ID (numeric)'),
    content_md:  z.string().describe('Markdown content to append'),
  },
  async ({ page_id, content_md }) => {
    const page = await getConfluencePage(page_id, process.env.CONFLUENCE_API_TOKEN!, process.env.CONFLUENCE_BASE_URL!);

    // Convert Markdown to Confluence storage format — minimal conversion
    // Real implementations should use a proper Confluence markup library
    const storageXhtml = content_md
      .split('\n\n')
      .map(para => `<p>${para.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`)
      .join('\n');

    const newBody = page.body.storage.value + '\n' + storageXhtml;

    const updateUrl = `${process.env.CONFLUENCE_BASE_URL}/rest/api/content/${page_id}`;
    const updateRes = await fetch(updateUrl, {
      method: 'PUT',
      headers: {
        Authorization: `Basic ${Buffer.from(`${process.env.CONFLUENCE_EMAIL}:${process.env.CONFLUENCE_API_TOKEN}`).toString('base64')}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        version: { number: page.version.number + 1 },  // must increment — 409 if wrong
        title:   page.title,
        type:    'page',
        body:    { storage: { value: newBody, representation: 'storage' } },
      }),
    });

    if (updateRes.status === 409) {
      // Concurrent edit — re-read and retry once
      const freshPage = await getConfluencePage(page_id, process.env.CONFLUENCE_API_TOKEN!, process.env.CONFLUENCE_BASE_URL!);
      // ... retry with freshPage.version.number + 1
      return { content: [{ type: 'text', text: 'Concurrent edit detected — retry required' }], isError: true };
    }

    if (!updateRes.ok) throw new Error(`Confluence PUT ${page_id}: ${updateRes.status} ${await updateRes.text()}`);
    return { content: [{ type: 'text', text: `Page ${page_id} updated to version ${page.version.number + 1}` }] };
  }
);

Google Sheets: two batchUpdate endpoints that must not be mixed

Google Sheets has two entirely separate batchUpdate endpoints that look similar in the documentation but accept completely different request schemas and return different errors when called incorrectly. spreadsheets.batchUpdate handles structural operations: adding sheets, renaming sheets, merging cells, formatting ranges, adding conditional formatting rules, and setting column widths. spreadsheets.values.batchUpdate handles data operations: writing cell values across multiple ranges in a single API call. Sending a values operation body to spreadsheets.batchUpdate returns a schema validation error; sending a structural operation to spreadsheets.values.batchUpdate returns a different schema error. Both errors are accurate but not always self-descriptive about which endpoint was called.

import { google } from 'googleapis';

const sheets = google.sheets({ version: 'v4' });

// Correct: structural operation via spreadsheets.batchUpdate
async function addSheet(spreadsheetId: string, sheetTitle: string, auth: any) {
  return sheets.spreadsheets.batchUpdate({
    spreadsheetId,
    auth,
    requestBody: {
      requests: [{
        addSheet: {
          properties: { title: sheetTitle },
        },
      }],
    },
  });
}

// Correct: data write via spreadsheets.values.batchUpdate
async function writeValues(spreadsheetId: string, range: string, values: string[][], auth: any) {
  return sheets.spreadsheets.values.batchUpdate({
    spreadsheetId,
    auth,
    requestBody: {
      valueInputOption: 'RAW',  // never USER_ENTERED for LLM-generated content
      data: [{ range, values }],
    },
  });
}

// Safe append — inserts new rows rather than overwriting existing data
server.tool(
  'append_to_google_sheet',
  {
    spreadsheet_id: z.string().describe('Spreadsheet ID from the URL between /d/ and /edit'),
    sheet_name:     z.string().describe('Sheet tab name — only used for value range, not structural ops'),
    rows:           z.array(z.array(z.string())).describe('Rows to append — each row is an array of string values'),
  },
  async ({ spreadsheet_id, sheet_name, rows }) => {
    const auth = new google.auth.GoogleAuth({
      credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON!),
      scopes: ['https://www.googleapis.com/auth/spreadsheets'],
    });
    const authClient = await auth.getClient();

    // Use spreadsheets.values.append with INSERT_ROWS to add rows without overwriting
    const range = `${sheet_name}!A1`;
    const res = await sheets.spreadsheets.values.append({
      spreadsheetId: spreadsheet_id,
      range,
      valueInputOption: 'RAW',         // strings stored literally — no formula interpretation
      insertDataOption: 'INSERT_ROWS',  // shift existing rows down rather than overwrite
      auth: authClient as any,
      requestBody: { values: rows },
    });

    return {
      content: [{ type: 'text', text: `Appended ${rows.length} rows. Updated range: ${res.data.updates?.updatedRange}` }],
    };
  }
);

Pattern 3 — HMAC Webhook Verification: Three Patterns Across Five Tools

Webhooks from productivity APIs carry write actions — a task was updated, a page was changed, an issue transitioned — and an MCP server that processes them without signature verification is a command injection surface. Any caller who can reach the webhook endpoint can forge events. Linear and Asana both use HMAC-SHA256 signatures, but with different header names, different handshake patterns, and different failure modes that make copying implementation from one to the other produce a broken handler without a clear error.

Linear webhooks: Linear-Signature header, no handshake

Linear sends a Linear-Signature header with every webhook delivery. The value is the HMAC-SHA256 hex digest of the raw request body, keyed with your webhook secret. There is no special handshake — the first delivery is a real event. The most common implementation error is computing the HMAC of the parsed-and-re-serialized JSON body rather than the raw bytes received from Linear. Re-serialized JSON can differ from the original in whitespace, key ordering, or Unicode normalization, producing a signature mismatch that is intermittent (works for simple payloads, fails for complex ones) and difficult to debug.

import crypto from 'crypto';
import express from 'express';

const app = express();

// CRITICAL: use express.raw() to capture the raw body buffer BEFORE JSON parsing.
// express.json() discards the raw bytes — you cannot reconstruct them from req.body.
app.use('/webhooks/linear', express.raw({ type: 'application/json' }));

app.post('/webhooks/linear', (req, res) => {
  const signature = req.headers['linear-signature'] as string | undefined;
  if (!signature) {
    res.status(400).send('Missing Linear-Signature header');
    return;
  }

  // req.body is a Buffer because of express.raw() above
  const expected = crypto
    .createHmac('sha256', process.env.LINEAR_WEBHOOK_SECRET!)
    .update(req.body)           // raw body Buffer — not JSON.stringify(req.body)
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  const sigBuf  = Buffer.from(signature,  'utf8');
  const expBuf  = Buffer.from(expected, 'utf8');
  const valid   = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);

  if (!valid) {
    res.status(401).send('Invalid signature');
    return;
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload.type (e.g. 'Issue', 'Comment') and payload.action (e.g. 'create', 'update')
  res.status(200).send('ok');
});

Asana webhooks: two-phase handshake then HMAC verification

Asana's webhook delivery protocol has two phases that must be handled differently. When a webhook is first registered, Asana sends a handshake POST with an X-Hook-Secret header and an empty body. The handler must respond with 200 and echo the same value in a response X-Hook-Secret header — no HMAC involved. Asana then stores this secret and uses it to sign all subsequent webhook deliveries with HMAC-SHA256 in the X-Hook-Signature header. A handler that tries to verify the HMAC on the first handshake delivery will fail (the body is empty, and the HMAC of an empty body won't match any stored secret), causing Asana to mark the webhook as failed and stop delivering events before the first real event is received.

// Asana webhook handler — must distinguish handshake from normal delivery
app.use('/webhooks/asana', express.raw({ type: 'application/json' }));

// Persist the webhook secret keyed by webhook GID
const webhookSecrets = new Map<string, string>();

app.post('/webhooks/asana', async (req, res) => {
  const hookSecret   = req.headers['x-hook-secret'] as string | undefined;
  const hookSig      = req.headers['x-hook-signature'] as string | undefined;
  const webhookGid   = req.headers['x-hook-resource-id'] as string | undefined;  // not standard; use path param if needed

  // Phase 1: handshake — echo the secret back in the response header
  if (hookSecret) {
    // Store the secret for future deliveries
    if (webhookGid) webhookSecrets.set(webhookGid, hookSecret);
    res.setHeader('X-Hook-Secret', hookSecret);
    res.status(200).send('');
    return;
  }

  // Phase 2: real delivery — verify HMAC-SHA256 signature
  if (!hookSig) {
    res.status(400).send('Missing X-Hook-Signature');
    return;
  }

  const secret = webhookGid ? webhookSecrets.get(webhookGid) : process.env.ASANA_WEBHOOK_SECRET;
  if (!secret) {
    res.status(400).send('Unknown webhook — secret not registered');
    return;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('hex');

  const sigBuf = Buffer.from(hookSig, 'utf8');
  const expBuf = Buffer.from(expected, 'utf8');
  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
    res.status(401).send('Invalid signature');
    return;
  }

  const events = JSON.parse(req.body.toString('utf8')).events as Array<{ resource: { gid: string; resource_type: string }; action: string }>;
  // Process events — each has resource.gid, resource.resource_type, action
  res.status(200).send('ok');
});

Pattern 4 — Write-Safe Update Operations: Version Checks, Typecast Traps, and Mutually Exclusive Fields

Write operations on productivity APIs fail in ways that are harder to detect in development than in production: version conflicts require concurrent users or automated systems to trigger, typecast side effects require Select fields with mismatched options, and mutually exclusive date fields only surface when an agent sets a deadline and a user reads it back and finds it has been cleared. Each tool has its own write-safety contract.

Confluence: version.number + 1 and the 409 race

Confluence page updates require the client to supply the next version number — the API does not accept an update without a version object containing number: currentVersion + 1. If two processes read a page at version 5 and both try to update to version 6, the second PUT returns 409. The correct pattern is to always read immediately before writing (not cache the version from an earlier read), handle 409 by re-reading and retrying once, and then surface a conflict error if the second attempt also returns 409 (indicating a third concurrent editor). An MCP tool that reads a page, does several seconds of LLM processing, then writes back is especially vulnerable to this race — other users can edit the page in the gap.

Airtable: typecast: true as a data integrity hazard

Airtable's typecast: true option tells the API to coerce string values into the appropriate field type on write. For a Single Select field with options ["Active", "Inactive", "Pending"], sending typecast: true with the value "active" will match the existing "Active" option (case-insensitive). Sending typecast: true with the value "Archived" — a value that doesn't exist in the field's option list — will silently create a new "Archived" option and write it. This is dangerous for LLM-generated content: if the calling agent hallucinates a status value or uses a synonym, typecast: true will create a new option and pollute the schema rather than returning an error. The safe pattern is typecast: false (the default) plus explicit validation of string values against the field schema before writing.

// Safe Airtable write: validate Select field values before writing
async function safeAirtableWrite(
  baseId: string,
  tableId: string,
  fields: Record<string, unknown>,
  apiKey: string,
): Promise<{ success: true; recordId: string } | { success: false; error: string }> {
  // Fetch the table schema to validate Select field options
  const schemaRes = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  const schema = await schemaRes.json();
  const table  = schema.tables.find((t: any) => t.id === tableId);
  if (!table) return { success: false, error: `Table ${tableId} not found` };

  for (const [fieldId, value] of Object.entries(fields)) {
    const fieldSchema = table.fields.find((f: any) => f.id === fieldId);
    if (!fieldSchema) return { success: false, error: `Field ${fieldId} not found in table ${tableId}` };

    // Validate single and multi-select values against schema options
    if (fieldSchema.type === 'singleSelect' || fieldSchema.type === 'multipleSelects') {
      const validOptions: string[] = fieldSchema.options?.choices?.map((c: any) => c.name) ?? [];
      const values = Array.isArray(value) ? value : [value];
      for (const v of values) {
        if (!validOptions.includes(v as string)) {
          return {
            success: false,
            error: `Invalid option "${v}" for field "${fieldSchema.name}". Valid options: ${validOptions.join(', ')}`,
          };
        }
      }
    }
  }

  // Write with typecast: false (the default) — validated above, no coercion needed
  const writeRes = await fetch(`https://api.airtable.com/v0/${baseId}/${tableId}`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ fields }),  // no typecast key = typecast: false
  });

  if (!writeRes.ok) return { success: false, error: `Airtable write error: ${await writeRes.text()}` };
  const record = await writeRes.json();
  return { success: true, recordId: record.id };
}

Asana: due_on vs due_at mutual exclusivity

Asana tasks have two date fields: due_on (a date string in YYYY-MM-DD format, representing a calendar day without a specific time) and due_at (an ISO 8601 datetime string with timezone, representing a specific moment in time). They are mutually exclusive: setting due_on clears due_at, and setting due_at clears due_on. The failure mode for LLM-generated input is that the calling agent provides a datetime string for a task where a date string is appropriate, or the tool includes both fields in an update and silently clears one. The fix is to accept only one of the two in each update call and never include both.

server.tool(
  'update_asana_task_deadline',
  {
    task_gid:     z.string(),
    deadline_type: z.enum(['date', 'datetime']).describe('"date" for due_on (YYYY-MM-DD), "datetime" for due_at (ISO 8601)'),
    deadline:     z.string().describe('The deadline value — format must match deadline_type'),
  },
  async ({ task_gid, deadline_type, deadline }) => {
    // Only include one of due_on or due_at — never both
    const updateFields = deadline_type === 'date'
      ? { due_on: deadline, due_at: null }     // null clears due_at if set
      : { due_at: deadline, due_on: null };     // null clears due_on if set

    await asana.tasks.updateTask(task_gid, { data: updateFields });
    return { content: [{ type: 'text', text: `Task ${task_gid} deadline updated to ${deadline}` }] };
  }
);

Linear: rawRequest() for mutations the typed SDK doesn't expose

Linear's typed SDK covers the most common operations but does not expose all GraphQL mutations — bulk operations, combined mutations via GraphQL aliases, and some less-common fields are only accessible via linearClient.rawRequest(). Bulk label assignment on multiple issues in a single round trip is a common example: the SDK exposes updateIssue({ labelIds: [...] }) for single issues, but there is no typed batch method. Using GraphQL aliases via rawRequest() allows sending multiple mutations in one API call without iterating issues sequentially.

// Bulk label assignment via rawRequest() — not available in the typed SDK
async function bulkAddLabelToIssues(issueIds: string[], labelId: string): Promise<void> {
  // GraphQL aliases allow multiple mutations in one request
  const aliases = issueIds.map((id, i) => `
    issue${i}: issueUpdate(id: "${id}", input: { labelIds: ["${labelId}"] }) {
      success
      issue { id identifier }
    }
  `).join('\n');

  const query = `mutation BulkLabelIssues { ${aliases} }`;
  await linearClient.rawRequest(query, {});
}

Failure Mode Reference: 18 Issues Across Five Tools

Integration Selection Guide

Tool Best for Stable ID param type Webhook support Write safety concern
Linear Engineering issue tracking, sprint planning, team workflow automation UUID GID from issue.id HMAC-SHA256 via Linear-Signature Label IDs vs names; team-scoped state IDs
Airtable Flexible relational data, non-technical team databases, campaign tracking Table ID (tbl...), field ID (fld...) None native (use automations or polling) typecast: true creates phantom options; attachment URLs expire
Google Sheets Reporting, data export, stakeholder dashboards, append-only logs Spreadsheet ID from URL None native (use Pub/Sub or Apps Script) valueInputOption RAW vs USER_ENTERED; two batchUpdate endpoints
Confluence Technical documentation, runbooks, knowledge base, meeting notes Space key (stable) + page ID (numeric) HTTP webhooks (no built-in HMAC) version.number + 1 required; storage XHTML not Markdown
Asana Cross-functional project management, deadline tracking, task dependencies GID (numeric string) on every resource HMAC-SHA256 with two-phase handshake due_on/due_at mutual exclusivity; opt_fields required