Model Context Protocol (MCP)
Create custom MCP servers so AI agents can access your backend tools over the standard MCP protocol.
Why Use MCP
Your AI agent needs to call tools hosted on an external server, or you want to expose your own backend logic as tools that any MCP-compatible client can discover and invoke.
Without MCP, you would have to build custom integration logic for every agent-to-tool connection. With MCP, you define tools on a server and any compatible agent can discover and call them through a standard protocol:
- TypeScript
- Python
// Backend: define an MCP server with a tool
@mcpServer({
name: 'weather',
id: 'weather',
description: 'Provides weather data',
version: '1.0.0',
})
export class WeatherMcpService extends SquidService {
@mcpTool({
description: 'Returns the current weather for a city',
inputSchema: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name' },
},
required: ['city'],
},
})
async getWeather({ city }: { city: string }): Promise<string> {
return `The weather in ${city} is sunny, 25°C.`;
}
}
# Backend: define an MCP server with a tool
@mcp_server({
'name': 'weather',
'id': 'weather',
'description': 'Provides weather data',
'version': '1.0.0',
})
class WeatherMcpService(SquidService):
@mcp_tool({
'description': 'Returns the current weather for a city',
'inputSchema': {
'type': 'object',
'properties': {
'city': {'type': 'string', 'description': 'City name'},
},
'required': ['city'],
},
})
async def get_weather(self, args: dict) -> str:
return f"The weather in {args['city']} is sunny, 25°C."
Any MCP-compatible AI agent can now discover and call the weather tool through the standard protocol.
Overview
MCP (Model Context Protocol) is an open protocol that standardizes how AI agents discover and invoke tools on external servers. Squid provides built-in support for creating MCP servers in your backend, letting you define tools as decorated methods that agents can call over JSON-RPC.
When to use MCP
| Use Case | Recommendation |
|---|---|
| Expose backend tools to any MCP-compatible agent | MCP server |
| Agent needs custom server-side logic during a conversation | Use AI functions |
| Agent needs to call tools on an external MCP server | Use an MCP connector |
| Agent needs to access a connected service (CRM, API, etc.) | Use a connected integration |
How it works
- You decorate a class with
@mcpServerin a service that extendsSquidService - You add tools to the server using
@mcpToolon methods within that class - Squid registers the MCP server at deploy time and exposes it as a JSON-RPC endpoint
- MCP-compatible clients connect, discover available tools via
tools/list, and call them viatools/call - Optionally, you enable OAuth or add an
@mcpAuthorizermethod to control access
Quick Start
Prerequisites
- TypeScript
- Python
- A Squid backend project initialized with
squid init - The
@squidcloud/backendpackage installed from NPM - An AI agent created (if you want to connect the MCP server to a Squid agent)
- A Squid backend project initialized with
squid init - The
squidcloud-backendpackage installed from PyPI - An AI agent created (if you want to connect the MCP server to a Squid agent)
Step 1: Create an MCP server with a tool
Create a service class that extends SquidService, decorate it with @mcpServer, and add a tool:
- TypeScript
- Python
import { mcpServer, mcpTool, SquidService } from '@squidcloud/backend';
@mcpServer({
name: 'greetingServer',
id: 'greetingServer',
description: 'A simple MCP server that greets users',
version: '1.0.0',
})
export class McpService extends SquidService {
@mcpTool({
description: 'Returns a greeting for the given name',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'The name to greet' },
},
required: ['name'],
},
})
async greet({ name }: { name: string }): Promise<string> {
return `Hello, ${name}!`;
}
}
from squidcloud_backend import SquidService, mcp_server, mcp_tool
@mcp_server({
'name': 'greetingServer',
'id': 'greetingServer',
'description': 'A simple MCP server that greets users',
'version': '1.0.0',
})
class McpService(SquidService):
@mcp_tool({
'description': 'Returns a greeting for the given name',
'inputSchema': {
'type': 'object',
'properties': {
'name': {'type': 'string', 'description': 'The name to greet'},
},
'required': ['name'],
},
})
async def greet(self, args: dict) -> str:
return f"Hello, {args['name']}!"
The Python tool method receives a single args: dict matching the inputSchema properties, rather than a destructured object as in TypeScript.
Step 2: Register the service
- TypeScript
- Python
Ensure the service is exported from the service index file:
export * from './mcp-service';
No separate registration step is needed. Python registers each @mcp_server class automatically when its module is imported, so the class only needs to live in (or be imported from) src/main.py.
Step 3: Deploy the backend
For local development, run the backend locally using the Squid CLI:
squid start
To deploy to the cloud, see deploying your backend.
Step 4: Connect the MCP server to an agent
After deployment, add your MCP server as a connector and attach it to an agent's abilities:
- Add an MCP connector in the Squid Console, pointing it to your deployed MCP server
- Add the connector to your agent's abilities in the Agent Studio
The agent can now discover and call your MCP tools during conversations.
Core Concepts
The @mcpServer decorator
The @mcpServer decorator marks a SquidService class as an MCP server. It accepts a configuration object with these fields:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier used in the MCP endpoint URL |
name | string | Yes | Server name exposed in the MCP manifest |
description | string | Yes | Describes the server's purpose |
version | string | Yes | Server version exposed in the MCP manifest |
oauth | McpOAuthOptions | No | Require OAuth 2.0 bearer tokens on every request. See OAuth authentication. |
Each id must be unique across all MCP servers in your application. Duplicate IDs cause a deployment error.
- TypeScript
- Python
import { mcpServer, SquidService } from '@squidcloud/backend';
@mcpServer({
id: 'inventory',
name: 'inventoryServer',
description: 'Provides product inventory lookup and management tools',
version: '1.0.0',
})
export class InventoryMcpService extends SquidService {
// Tools go here
}
from squidcloud_backend import SquidService, mcp_server
@mcp_server({
'id': 'inventory',
'name': 'inventoryServer',
'description': 'Provides product inventory lookup and management tools',
'version': '1.0.0',
})
class InventoryMcpService(SquidService):
pass # Tools go here
The @mcpTool decorator
The @mcpTool decorator exposes a method as a tool that MCP clients can discover and invoke. It accepts a configuration object with these fields:
| Field | Type | Required | Description |
|---|---|---|---|
description | string | Yes | Tells the agent what the tool does and when to call it |
inputSchema | JSONSchema | Yes | JSON Schema defining the tool's input parameters |
outputSchema | JSONSchema | No | JSON Schema describing the tool's output format |
The method name becomes the tool name in the MCP manifest. Each tool name must be unique within a server.
- TypeScript
- Python
@mcpTool({
description: 'Looks up the current stock level for a product by SKU',
inputSchema: {
type: 'object',
properties: {
sku: {
type: 'string',
description: 'The product SKU code',
},
},
required: ['sku'],
},
})
async getStockLevel({ sku }: { sku: string }): Promise<number> {
// Look up the product using your own data layer.
const product = await this.findProduct(sku);
if (!product) {
throw new Error(`Product with SKU ${sku} not found`);
}
return product.stockLevel;
}
@mcp_tool({
'description': 'Looks up the current stock level for a product by SKU',
'inputSchema': {
'type': 'object',
'properties': {
'sku': {
'type': 'string',
'description': 'The product SKU code',
},
},
'required': ['sku'],
},
})
async def get_stock_level(self, args: dict) -> int:
sku = args['sku']
# Look up the product using your own data layer.
product = await self.find_product(sku)
if product is None:
raise RuntimeError(f"Product with SKU {sku} not found")
return product['stockLevel']
Input schema
The inputSchema follows JSON Schema format. In TypeScript the tool method receives a single destructured object whose properties match the schema; in Python the method receives a single args: dict and reads each property by key:
- TypeScript
- Python
@mcpTool({
description: 'Searches products by category and price range',
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
description: 'Product category to search',
enum: ['electronics', 'clothing', 'home', 'sports'],
},
maxPrice: {
type: 'number',
description: 'Maximum price in USD',
},
inStockOnly: {
type: 'boolean',
description: 'If true, only return items currently in stock',
},
},
required: ['category'],
},
})
async searchProducts({
category,
maxPrice,
inStockOnly,
}: {
category: string;
maxPrice?: number;
inStockOnly?: boolean;
}): Promise<string> {
// Query logic here
return JSON.stringify(results);
}
@mcp_tool({
'description': 'Searches products by category and price range',
'inputSchema': {
'type': 'object',
'properties': {
'category': {
'type': 'string',
'description': 'Product category to search',
'enum': ['electronics', 'clothing', 'home', 'sports'],
},
'maxPrice': {
'type': 'number',
'description': 'Maximum price in USD',
},
'inStockOnly': {
'type': 'boolean',
'description': 'If true, only return items currently in stock',
},
},
'required': ['category'],
},
})
async def search_products(self, args: dict) -> str:
category = args['category']
max_price = args.get('maxPrice')
in_stock_only = args.get('inStockOnly')
# Query logic here
return json.dumps(results)
Output schema
The optional outputSchema describes the structure of the tool's return value, helping clients understand the response format:
- TypeScript
- Python
@mcpTool({
description: 'Returns product details for a given SKU',
inputSchema: {
type: 'object',
properties: {
sku: { type: 'string', description: 'Product SKU' },
},
required: ['sku'],
},
outputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
price: { type: 'number' },
inStock: { type: 'boolean' },
},
},
})
async getProduct({ sku }: { sku: string }) {
return { name: 'Widget', price: 9.99, inStock: true };
}
@mcp_tool({
'description': 'Returns product details for a given SKU',
'inputSchema': {
'type': 'object',
'properties': {
'sku': {'type': 'string', 'description': 'Product SKU'},
},
'required': ['sku'],
},
'outputSchema': {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'price': {'type': 'number'},
'inStock': {'type': 'boolean'},
},
},
})
async def get_product(self, args: dict) -> dict:
return {'name': 'Widget', 'price': 9.99, 'inStock': True}
The @mcpAuthorizer decorator
The @mcpAuthorizer decorator designates a method that runs before every request to the MCP server. Use it to validate incoming requests and reject unauthorized callers.
The authorizer method receives an McpAuthorizationRequest object and must return a boolean (or Promise<boolean>). Return true to allow the request, or false to reject it with an "Unauthorized" error.
- TypeScript
- Python
import { mcpAuthorizer, McpAuthorizationRequest, mcpServer, mcpTool, SquidService } from '@squidcloud/backend';
@mcpServer({
name: 'secureMcp',
id: 'secureMcp',
description: 'An MCP server with authorization',
version: '1.0.0',
})
export class SecureMcpService extends SquidService {
@mcpAuthorizer()
async authorize(request: McpAuthorizationRequest): Promise<boolean> {
const token = request.headers['authorization'];
return token === `Bearer ${this.secrets['MCP_AUTH_TOKEN']}`;
}
@mcpTool({
description: 'Returns sensitive data',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'The data query' },
},
required: ['query'],
},
})
async getSensitiveData({ query }: { query: string }): Promise<string> {
// This tool is only accessible if the authorizer returns true
return `Results for: ${query}`;
}
}
from squidcloud_backend import (
McpAuthorizationRequest,
SquidService,
mcp_authorizer,
mcp_server,
mcp_tool,
)
@mcp_server({
'name': 'secureMcp',
'id': 'secureMcp',
'description': 'An MCP server with authorization',
'version': '1.0.0',
})
class SecureMcpService(SquidService):
@mcp_authorizer()
async def authorize(self, request: McpAuthorizationRequest) -> bool:
headers = request.get('headers') or {}
token = headers.get('authorization')
return token == f"Bearer {self.secrets['MCP_AUTH_TOKEN']}"
@mcp_tool({
'description': 'Returns sensitive data',
'inputSchema': {
'type': 'object',
'properties': {
'query': {'type': 'string', 'description': 'The data query'},
},
'required': ['query'],
},
})
async def get_sensitive_data(self, args: dict) -> str:
# This tool is only accessible if the authorizer returns True
return f"Results for: {args['query']}"
McpAuthorizationRequest fields
| Field | Type | Description |
|---|---|---|
body | any | The parsed JSON-RPC request body |
queryParams | Record<string, string> | Query parameters from the request URL |
headers | Record<string, string> | HTTP headers from the request |
auth | McpAuthContext | undefined | Validated auth context, present only when the request passed OAuth validation |
If no @mcpAuthorizer method is defined and OAuth is not enabled, all requests to the MCP server are allowed.
OAuth authentication
For MCP clients used by end users, such as Claude or other MCP-compatible assistants, a shared secret token is impractical: each user needs to sign in with their own identity. Adding the oauth option to @mcpServer requires a valid OAuth 2.0 bearer token on every request:
import { mcpServer, mcpTool, SquidService } from '@squidcloud/backend';
@mcpServer({
id: 'crm',
name: 'crmServer',
description: 'Provides CRM lookup tools',
version: '1.0.0',
oauth: {
integrationId: 'my-auth0',
},
})
export class CrmMcpService extends SquidService {
// Tools go here
}
McpOAuthOptions has a single field:
| Field | Type | Required | Description |
|---|---|---|---|
integrationId | string | Yes | The ID of an auth integration registered for this app |
MCP OAuth currently supports Auth0 auth integrations and is available in the TypeScript Backend SDK only. The Python @mcp_server decorator does not accept an oauth option.
When oauth is set, Squid handles the resource-server side of the OAuth flow automatically:
- Requests without a valid bearer token receive an HTTP
401 Unauthorizedresponse with aWWW-Authenticatechallenge pointing at the server's protected resource metadata. - Squid exposes an OAuth 2.0 Protected Resource Metadata document at
<mcp-server-url>/.well-known/oauth-protected-resource, listing your Auth0 tenant as the authorization server. MCP clients discover it and walk the user through the Auth0 login automatically. - Squid validates the bearer token on every request against the referenced integration: the token must be RS256-signed by your Auth0 tenant (verified via its JWKS), with the tenant domain as issuer and the integration's client ID as audience.
The requested scopes are openid, profile, email, and offline_access.
With oauth enabled and no @mcpAuthorizer, any request carrying a valid token from the integration is allowed.
Combining OAuth with an authorizer
To apply your own rules on top of token validation, such as restricting access to specific users or domains, add an @mcpAuthorizer. The authorizer runs after OAuth validation succeeds, and the validated context is available on request.auth:
import { mcpAuthorizer, McpAuthorizationRequest, mcpServer, mcpTool, SquidService } from '@squidcloud/backend';
@mcpServer({
id: 'crm',
name: 'crmServer',
description: 'Provides CRM lookup tools',
version: '1.0.0',
oauth: {
integrationId: 'my-auth0',
},
})
export class CrmMcpService extends SquidService {
@mcpAuthorizer()
async authorize(request: McpAuthorizationRequest): Promise<boolean> {
const email = request.auth?.claims['email'] as string | undefined;
return !!email && email.endsWith('@my-company.com');
}
// Tools go here
}
McpAuthContext fields:
| Field | Type | Description |
|---|---|---|
type | 'oauth' | The auth mechanism that produced this context |
integrationId | string | The ID of the auth integration used to validate the request |
claims | Record<string, unknown> | Validated JWT claims (sub, aud, iss, exp, custom claims) |
Validated claims are available only inside the @mcpAuthorizer; @mcpTool methods receive only their input arguments. Enforce identity-based access centrally in the authorizer rather than expecting per-user context inside tools.
Error Handling
Tool errors
When a tool method throws an error, the MCP server catches it and returns it as a tool response with isError: true. The calling agent receives the error message and can relay it or decide how to proceed.
Throw clear, descriptive errors so the agent can provide useful feedback:
- TypeScript
- Python
@mcpTool({
description: 'Cancels an order by ID',
inputSchema: {
type: 'object',
properties: {
orderId: { type: 'string', description: 'The order ID to cancel' },
},
required: ['orderId'],
},
})
async cancelOrder({ orderId }: { orderId: string }): Promise<string> {
// Look up the order using your own data layer.
const order = await this.findOrder(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
if (order.status === 'shipped') {
throw new Error(`Order ${orderId} has already shipped and cannot be cancelled`);
}
await this.updateOrderStatus(orderId, 'cancelled');
return `Order ${orderId} has been cancelled`;
}
@mcp_tool({
'description': 'Cancels an order by ID',
'inputSchema': {
'type': 'object',
'properties': {
'orderId': {'type': 'string', 'description': 'The order ID to cancel'},
},
'required': ['orderId'],
},
})
async def cancel_order(self, args: dict) -> str:
order_id = args['orderId']
# Look up the order using your own data layer.
order = await self.find_order(order_id)
if order is None:
raise RuntimeError(f"Order {order_id} not found")
if order['status'] == 'shipped':
raise RuntimeError(f"Order {order_id} has already shipped and cannot be cancelled")
await self.update_order_status(order_id, 'cancelled')
return f"Order {order_id} has been cancelled"
Protocol-level errors
The MCP server uses standard JSON-RPC error codes for protocol-level issues:
| Error Code | Meaning | Cause |
|---|---|---|
-32001 | Unauthorized | The @mcpAuthorizer method returned false |
-32601 | Method not found | The requested JSON-RPC method or tool name does not exist |
-32000 | Server error | The MCP server ID was not found, or an internal error occurred |
When OAuth is enabled, requests with a missing or invalid bearer token are rejected before any JSON-RPC processing with an HTTP 401 Unauthorized response carrying a WWW-Authenticate challenge.
Common issues
| Issue | Cause | Solution |
|---|---|---|
| Deployment fails with duplicate ID error | Two @mcpServer classes use the same id | Use a unique id for each MCP server |
| Deployment fails with duplicate tool name | Two @mcpTool methods have the same name in one server | Rename one of the methods |
| Tool is never called by the agent | Tool description does not match user prompts | Rewrite the description to clearly state what the tool does |
| Authorization always fails | Token or header check is incorrect | Log the McpAuthorizationRequest fields to debug |
OAuth returns 401 invalid_token on every request | Token issuer or audience doesn't match the integration | Verify the token comes from the integration's Auth0 tenant; the audience must be the integration's client ID |
| OAuth-enabled server rejects all requests | oauth.integrationId doesn't reference a registered Auth0 integration | Register the Auth0 integration for the app and use its ID |
Best Practices
Write clear tool descriptions
The description is the primary way agents determine when to call a tool. Be specific about what the tool does and what information it returns:
Good: 'Returns the current stock level for a product. Use when asked about inventory or availability.'
Bad: 'Gets product info'
Design input schemas carefully
- Mark parameters as
requiredonly when the tool cannot function without them - Use
enumto constrain values to a known set - Write clear
descriptionfields for each property so the agent knows what format to provide - Use appropriate JSON Schema types (
string,number,boolean,array,object)
Secure your MCP servers
- Always add an
@mcpAuthorizerwhen the server exposes sensitive operations - Validate authorization tokens against stored secrets rather than hardcoded values
- Check both authentication (who is calling) and authorization (what they can do)
- For user-facing MCP clients such as Claude, prefer OAuth authentication over shared tokens, and apply per-user rules in the authorizer via
request.auth?.claims
Validate tool inputs
Even though the input schema provides type constraints, validate inputs in your tool methods to handle edge cases:
- TypeScript
- Python
@mcpTool({
description: 'Transfers funds between accounts',
inputSchema: {
type: 'object',
properties: {
fromAccount: { type: 'string', description: 'Source account ID' },
toAccount: { type: 'string', description: 'Destination account ID' },
amount: { type: 'number', description: 'Amount to transfer in USD' },
},
required: ['fromAccount', 'toAccount', 'amount'],
},
})
async transferFunds({
fromAccount,
toAccount,
amount,
}: {
fromAccount: string;
toAccount: string;
amount: number;
}): Promise<string> {
if (amount <= 0) {
throw new Error('Transfer amount must be positive');
}
if (fromAccount === toAccount) {
throw new Error('Source and destination accounts must be different');
}
// Process transfer...
return `Transferred $${amount} from ${fromAccount} to ${toAccount}`;
}
@mcp_tool({
'description': 'Transfers funds between accounts',
'inputSchema': {
'type': 'object',
'properties': {
'fromAccount': {'type': 'string', 'description': 'Source account ID'},
'toAccount': {'type': 'string', 'description': 'Destination account ID'},
'amount': {'type': 'number', 'description': 'Amount to transfer in USD'},
},
'required': ['fromAccount', 'toAccount', 'amount'],
},
})
async def transfer_funds(self, args: dict) -> str:
from_account = args['fromAccount']
to_account = args['toAccount']
amount = args['amount']
if amount <= 0:
raise RuntimeError('Transfer amount must be positive')
if from_account == to_account:
raise RuntimeError('Source and destination accounts must be different')
# Process transfer...
return f"Transferred ${amount} from {from_account} to {to_account}"
Keep tools focused
Each tool should do one thing well. Prefer multiple small, focused tools over a single tool that handles many operations. This helps the agent select the right tool for the task.
Next Steps
- MCP connectors - Connect your agent to an MCP server through the Squid Console
- Auth0 authentication - Set up the Auth0 integration used for MCP OAuth
- Abilities - Attach MCP connectors and other tools to your agent
- AI functions - An alternative way to expose backend logic to agents
- AI agent documentation - Build and configure AI agents