Note: This article is intended for a technical audience who wish to build their own custom MCP server and connect to Sana’s MCP connector. If you already have an MCP server and want to connect it to Sana, see the general MCP article here instead.
Introduction
Sana’s MCP connector lets you connect Sana to thousands of external tools, services, and data sources using the Model Context Protocol (MCP), an open‑source standard for AI-tool integrations. This gives your workspace greater flexibility and makes it possible to unlock new use cases by integrating tools built by your team.
In this article, we’ve compiled best practices for building MCP servers that integrate well with Sana. With that said, this is not a step-by-step guide on how to build a MCP server from start to finish as there are already plenty of material online on how to do that. If you are interested in learning more about how to build a MCP server from start to finish, please read more on MCPs official website here. Instead, this guide focuses specifically on our learnings from building MCP integrations that performs well with Sana’s MCP connector and agentic framework to help you improve both the quality and performance of your custom MCP server.
Summary of best practices
In summary, we have 7 best practices across three categories: Tool design and semantics, Safety and security, and Performance:
Tool design and semantics
Name tools based on their purpose: Use clear, action‑oriented names that describe what the tool does, not how it is implemented.
Add tool annotations to distinguish between read and write actions: Explicitly mark tools as read‑only or write‑capable to flag which tool that requires human approval.
Use rich Markdown in tool descriptions: Provide precise, well‑structured descriptions with examples so the agent can reliably call the tool.
Safety and security
Use OAuth when possible: Prefer OAuth for authenticating against external systems to improve security and user management.
Return actionable errors: Surface clear, structured error messages that allow the agent to recover or prompt the user meaningfully.
Implement schema validation for rich error messages: Validate inputs against schemas and return detailed validation errors instead of generic failures.
Performance
Implement caching to avoid expensive lookups: Cache frequent or costly reads to reduce latency and load on downstream systems.
More details and examples for each best practice are provided in upcoming sections of this article.
Best practices
Tool design and semantics
Name tools based on their purpose:
To help the LLM choose the correct tool for the intended action, it is important to name tools based on their purpose. For example, prefer “search‑companies” or “Search companies” instead of a generic “companies”. This makes it clearer what the tool actually does and when it should be used.
Figure 1: Example of tool naming from Linear’s official MCP
Add tool annotations to distinguish between read and write actions
Sana has implemented guardrails and enterprise‑readiness features such as:
Human‑in‑the‑loop: The user must confirm or deny a tool call when write tools are invoked, reducing the risk of the agent performing unwanted or potentially harmful actions, see Figure 2 below.
Tool configurations: When setting up an MCP, the workspace owner can configure which types of tools are allowed for users, see Figure 3 below.
Figure 2: Example of a human-in-the-loop when creating a task in HubSpot
.
Figure 3: Tool Configuration options when setting up an MCP in Sana
To take advantage of human‑in‑the‑loop and tool configuration features in Sana, you need to annotate your tools correctly. If readOnlyHint is set to true, the tool is treated as read‑only. Read‑only tools can be executed without triggering a human‑in‑the‑loop popup. If destructiveHint is set to true, the tool is treated as a write tool, and a human‑in‑the‑loop popup will be shown when the tool is invoked.
interface ToolHints {
readOnlyHint: boolean; // true = won't modify data
destructiveHint: boolean; // true = modifies/deletes data
idempotentHint: boolean; // true = safe to retry
}
Use rich Markdown in tool descriptions
Help guide the agent on when a tool should or should not be called by using rich Markdown in tool descriptions. This makes it easier for the agent to understand the tool’s purpose, proper usage, and common pitfalls, see example below.
description: `**Purpose:** Find entries matching criteria
**CRITICAL - When to Use:**
ANY query with patterns: "Show all X companies"
ALWAYS use filterGroups for pattern matching
Don't use when:
- Need single entry by ID → use get-entry
**Field Discovery Workflow (REQUIRED):**
1. FIRST: Use list-properties to see available fields
2. THEN: Build filters with confirmed field names
3. NEVER guess field names
**Common Pitfalls:**
- Guessing "Name" → Discover it's "Company Name"
- Text operators on relationships → Only EQ/IS_NULL work
**Examples:**
- "Find Healthcare companies" → filter Industry CONTAINS "Healthcare"
Safety and security
Use OAuth when possible
Prefer OAuth when authenticating against external systems as it improves security, makes it easier to rotate credentials, and simplifies user and permission management. We support the following auth methods OAuth (Dynamic Client Registration (DCR) or Static credentials), API Key or No authentication, see screenshot below how it looks like during set up for Workspace owners in Sana.
Return actionable errors
Return actionable errors with “Did you mean?”‑style suggestions to guide both the agent and end users when something goes wrong, see example below.
class EnhancedError extends Error {
errorCode: string; // e.g., "FIELD_NOT_FOUND"
context: Record<string, unknown>;
suggestions: string[]; // Actionable advice
alternatives: string[]; // Similar matches
}
// Levenshtein distance for suggestions
function createFieldNotFoundError(fieldName: string, availableFields: string[]) {
const similar = availableFields
.map(f => ({ field: f, distance: levenshtein(fieldName, f) }))
.filter(f => f.distance <= 3)
.sort((a, b) => a.distance - b.distance)
.slice(0, 3);
return new EnhancedError({
message: `Field "${fieldName}" not found`,
suggestions: [`Use dealcloud-list-properties to see available fields`],
alternatives: similar.map(s => s.field),
});
}
Implement schema validation for rich error messages
Use a schema validation library such as Zod to validate MCP requests and return structured, readable error messages, see example below.
// Union types enforce operator-value consistency
const FilterSchema = z.union([
// Null operators (no value)
z.object({
propertyName: z.string(),
operator: z.enum(["IS_NULL", "IS_NOT_NULL"]),
}),
// Value operators (require value)
z.object({
propertyName: z.string(),
operator: z.enum(["EQ", "CONTAINS", "STARTS_WITH"]),
value: z.union([z.string(), z.number(), z.boolean()]),
}),
]);
// Cross-field validation
const GetEntrySchema = z.object({
entryType: z.string(),
entryId: z.number().optional(),
entryName: z.string().optional(),
}).refine(
(data) => data.entryId || data.entryName,
{ message: "Either entryId or entryName required" }
);
Performance
Implement caching to avoid expensive lookups
To keep MCPs fast and scalable, add a light caching layer around expensive or frequently repeated operations. This typically covers:
Startup preparation, such as resolving dynamic entry types or object definitions once and keeping them in memory to avoid expensive lookups.
Access tokens, so your MCP doesn’t request a new OAuth token for every call.
// EntryType name → ID mapping (warm on startup)
const entryTypeCache = new Map<string, number>();
// Store multiple keys: original, lowercase, apiName
entryTypeCache.set("Company", 123);
entryTypeCache.set("company", 123);
entryTypeCache.set("companies", 123);
// Field schemas (lazy, per-EntryType)
const fieldCache = new Map<number, FieldSchema[]>();
async function getFields(entryTypeId: number) {
if (!fieldCache.has(entryTypeId)) {
fieldCache.set(entryTypeId, await fetchFields(entryTypeId));
}
return fieldCache.get(entryTypeId);
}
// OAuth tokens (with expiry buffer)
let tokenCache = { token: null, expiresAt: 0 };
async function getToken() {
if (Date.now() < tokenCache.expiresAt - 60000) return tokenCache.token;
// Refresh with deduplication
if (!tokenRefreshPromise) tokenRefreshPromise = refreshToken();
return tokenRefreshPromise;
}
Useful resources
There is already plenty of material online on how to build MCP servers. A few resources we recommend:
The official MCP docs: https://modelcontextprotocol.io
The official MCP docs on how to build an MCP server: https://modelcontextprotocol.io/docs/develop/build-server
The official MCP docs on how to work with tools: https://modelcontextprotocol.io/legacy/concepts/tools
Sana’s docs about the MCP connector: https://support.sana.ai/en/articles/357502-mcp-integration-guide
Debug MCP servers locally with MCP Inspector: https://modelcontextprotocol.io/docs/tools/inspector
Hook up your MCP server when developing with Claude Code: https://claude.com/product/claude-code

