Function calling lets the agent ask the runtime to do something concrete: look up a record, send an email, write to a collection, hit a third-party API. AppEngine wraps this in an MCP-style (Model Context Protocol) tool registry. Tools come in two flavours: server-side tools registered in McpToolsService and executed inside AppEngine, and client-side tools that the client app declares per request and executes itself when the agent calls back.
Endpoints
/ai/mcp/toolsJWT/ai/mcp/executeJWTDiscovering tools
const { tools } = await fetch('/ai/mcp/tools', {
headers: { orgid: 'my-org', Authorization: `Bearer ${jwt}` },
}).then(r => r.json());
// [{ name: 'server.memory.store', description: '...', inputSchema: {...} }, ...]
Each tool has a name, a description, an inputSchema (JSON Schema), and a category. The agent reads this list and picks tools to call based on the conversation.
Built-in server tools
The default registry includes:
| Tool | What it does |
|---|---|
server.memory.store | Stash data under a key in Redis (per org/user) |
server.memory.retrieve | Read it back |
server.memory.delete | Forget a key |
server.repository.find | Run a Mongo-style query against any collection in the caller's org |
server.repository.create / update / delete | CRUD on collections |
server.notification.send | Trigger a notification template |
Plus domain tools registered by other modules (CRM, Storefront, Banking) when they import the AI module.
Calling a tool directly
The MCP execute endpoint runs a tool from a non-agent caller — useful for the chat client UI ("Show me my saved searches" → fetch from memory directly).
const result = await fetch('/ai/mcp/execute', {
method: 'POST',
headers: {
orgid: 'my-org',
Authorization: `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
toolName: 'server.memory.retrieve',
args: { key: 'my-saved-prompt' },
}),
}).then(r => r.json());
orgId and userId are injected from the JWT — even if you pass them in args, the server-side values win. This stops a tool call from leaking across tenants.
Agent-driven tool calls
When an agent decides to call a tool during a streamed run, the chunk shape is:
{ "type": "tool_use", "id": "tool-1", "name": "server.repository.find", "input": { "collection": "contact", "query": { "data.tags": "vip" } } }
If it's a server-side tool, AppEngine executes it inline and pushes back:
{ "type": "tool_result", "id": "tool-1", "output": { "data": [...], "total": 42 } }
Then the agent continues — uses the result to compose its next answer.
Client-side tools
For tools the client implements (e.g. "open a tab", "play a sound", "write to local IndexedDB"), declare them in the request body:
await fetch('/ai/agent/stream', {
method: 'POST',
headers: { orgid: 'my-org', Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
task: 'Open the pricing page in a new tab.',
clientMcpTools: [
{
name: 'client.browser.openTab',
description: 'Open a URL in a new browser tab',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
},
],
}),
});
When the agent calls client.browser.openTab, the SSE stream emits a tool_use chunk. The client executes it and sends the result back via a follow-up POST:
// Client receives tool_use chunk and executes locally
window.open(input.url, '_blank');
// Then POSTs the result
await fetch('/ai/tool-result', {
method: 'POST',
headers: { orgid: 'my-org', Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
streamId,
toolUseId: 'tool-1',
output: { ok: true },
}),
});
Registering a server tool
Server tools are TypeScript code in McpToolsService (or a module that imports it and adds tools). To add a new tool, extend the registry list and provide an executor.
// In your module that consumes McpToolsService
mcpToolsService.registerTool(
{
name: 'crm.contact.tag',
description: 'Add a tag to a contact',
category: 'crm',
inputSchema: {
type: 'object',
properties: {
contactId: { type: 'string' },
tag: { type: 'string' },
},
required: ['contactId', 'tag'],
},
},
async (args, ctx) => {
// ctx has resolved orgId and userId from the principal
const contact = await this.repositoryService.get(ctx.orgId, 'contact', args.contactId);
contact.data.tags = [...(contact.data.tags || []), args.tag];
await this.repositoryService.update(ctx.orgId, contact.sk, contact, ctx.userId);
return { ok: true, tags: contact.data.tags };
},
);
Register on module init. After registration the tool appears in /ai/mcp/tools and is callable both via /ai/mcp/execute and from any agent run that doesn't restrict availableTools.
Whitelisting per call
By default an agent can call any registered tool. To restrict:
await fetch('/ai/agent/stream', {
method: 'POST',
headers: { /* ... */ },
body: JSON.stringify({
task: 'Find vip contacts.',
availableTools: ['server.repository.find'], // only this tool, nothing else
}),
});
Useful for safety (a content-only agent shouldn't be able to delete records) and cost (each tool call adds latency and tokens).
Error handling
Tool errors are pushed as a tool_result chunk with error set:
{ "type": "tool_result", "id": "tool-1", "error": { "message": "Contact not found" } }
The agent typically retries once with adjusted args, then explains the failure to the user. If you want different behaviour, set settings.toolErrorPolicy in the request — retry (default), abort (end the stream on error), or continue (let the agent decide).
For provider-native function calling (e.g. Claude tool use, OpenAI tool calls), the framework converts MCP tool definitions into the right shape for each provider on the way out and converts the result chunks back into the unified stream chunk format on the way in.