Client Side Tools

Client-side MCP Tools

The Stream Chat AI components allow you to execute client-side MCP tools. What that means, is that when the agent determines whether an MCP tool should be called, it’s also able to delegate that task to the client, in this case the mobile device.

This opens up a lot of possibilities - for example your app can access the user’s device data (calendar access, user location, health data, etc). You can also execute custom UI, like showing screens, forms, popups, alerts and similar. You can also potentially call app intents to execute other app’s actions.

To achieve this, we use the official Swift MCP SDK.

Create your own MCP Tool

In order to create your own MCP Tool, you need to implement the ClientTool protocol:

public protocol ClientTool: AnyObject {
    var toolDefinition: Tool { get }
    var instructions: String { get }
    var showExternalSourcesIndicator: Bool { get }

    func handleInvocation(_ invocation: ClientToolInvocation) -> [ClientToolAction]
}

ClientTool defines the interface for building tools that a client exposes to the MCP runtime. Each tool provides a toolDefinition describing its schema, optional human-readable instructions, and a flag indicating whether it accesses external data sources. When the model invokes a tool, the client calls handleInvocation to execute the tool’s logic and return one or more ClientToolAction results. Conforming to this protocol allows clients to extend the MCP environment with custom behaviors and integrations.

Greet Tool

In this guide, we will create an example greet tool, that will show a “greet” alert when the user asks to be greeted.

@MainActor
final class GreetClientTool: ClientTool {
    let toolDefinition: Tool = {
        let schema: Value = .object([
            "type": .string("object"),
            "properties": .object([:]),
            "required": .array([]),
            "additionalProperties": .bool(false)
        ])

        return Tool(
            name: "greetUser",
            description: "Display a native greeting to the user",
            inputSchema: schema,
            annotations: .init(title: "Greet user")
        )
    }()

    let instructions =
        "Use the greetUser tool when the user asks to be greeted. The tool shows a greeting alert in the iOS app."

    let showExternalSourcesIndicator = false

    func handleInvocation(_ invocation: ClientToolInvocation) -> [ClientToolAction] {
        [
            {
                ClientToolActionHandler.shared.presentAlert(
                    ClientToolAlert(
                        title: "Greetings!",
                        message: "👋 Hello there! The assistant asked me to greet you."
                    )
                )
            }
        ]
    }

The instructions help the model decide when this tool is appropriate, and showExternalSourcesIndicator controls whether the UI shows an “external data” badge when the tool is invoked.

handleInvocation(_:) is called when the AI agent decides to run the tool. Return one or more ClientToolAction closures, each performing the native work you want (UI updates, navigation, sensors, etc.). The greet tool returns a single closure to show an alert via a helper.

For simplicity, the greet tool takes no arguments, and the schema has no properties. For tools that take parameters, decode invocation.args with JSONDecoder before performing the action.

ClientToolActionHandler

Next, let’s see the ClientToolActionHandler, that handles the action passed to the tool.

import StreamChatAI

final class ClientToolActionHandler: ObservableObject, ClientToolActionHandling {
    static let shared = ClientToolActionHandler()

    @Published var presentedAlert: ClientToolAlert?

    private init() {}

    func handle(_ actions: [ClientToolAction]) {
        actions.forEach { action in
            action()
        }
    }

    func presentAlert(_ alert: ClientToolAlert) {
        presentedAlert = alert
    }
}

struct ClientToolAlert: Identifiable {
    let id = UUID()
    let title: String
    let message: String
}

When the presentedAlert value changes, you can use it in a SwiftUI view to render the alert:

@ObservedObject var toolActionHandler = ClientToolActionHandler.shared

var body: some View {
    /* … chat UI … */
    .alert(item: $toolActionHandler.presentedAlert) { alert in
        Alert(
            title: Text(alert.title),
            message: Text(alert.message),
            dismissButton: .default(Text("OK"))
        )
    }
}

Different tools can expose additional published properties (banners, navigation triggers, etc.) through a similar handler.

To wrap things up, you need to register the tool to the ClientToolRegistry:

let registry = ClientToolRegistry()
registry.register(tool: GreetClientTool())

The ClientToolRegistry manages the lifecycle and routing of client tools within an MCP-enabled client. It stores tools by name, exposes their metadata through registration payloads, and dispatches invocation requests to the correct tool instance. This registry is typically used during the client’s initialization process to make tools discoverable by the runtime and to centralize invocation routing logic.

Make sure that the registry is persisted in your app, for example as a state object.

Registering the tools server-side

You also need to notify your server-side agent about the client tools you are going to use. Our server-side integrations with the AI SDK and Langchain already expose a method that lets you register tools.

You need to expose your endpoint for registration with a code similar to this:

app.post('/register-tools', (req, res) => {
  const { channel_id, tools } = req.body ?? {};

  if (typeof channel_id !== 'string' || channel_id.trim().length === 0) {
    res.status(400).json({ error: 'Missing or invalid channel_id' });
    return;
  }

  if (!Array.isArray(tools)) {
    res.status(400).json({ error: 'Missing or invalid tools array' });
    return;
  }

  const channelIdNormalized = normalizeChannelId(channel_id);
  if (!channelIdNormalized) {
    res.status(400).json({ error: 'Invalid channel_id' });
    return;
  }

  const sanitizedTools: ClientToolDefinition[] = [];
  const invalidTools: string[] = [];

  tools.forEach((rawTool, index) => {
    const tool = rawTool ?? {};
    const rawName = typeof tool.name === 'string' ? tool.name.trim() : '';
    const rawDescription =
      typeof tool.description === 'string' ? tool.description.trim() : '';

    if (!rawName || !rawDescription) {
      invalidTools.push(
        typeof tool.name === 'string'
          ? tool.name
          : `tool_${index.toString().padStart(2, '0')}`,
      );
      return;
    }

    const instructions =
      typeof tool.instructions === 'string' && tool.instructions.trim().length > 0
        ? tool.instructions.trim()
        : undefined;

    const parameters = isPlainObject(tool.parameters)
      ? (JSON.parse(JSON.stringify(tool.parameters)) as ClientToolDefinition['parameters'])
      : undefined;

    let showExternalSourcesIndicator: boolean | undefined;
    if (typeof tool.showExternalSourcesIndicator === 'boolean') {
      showExternalSourcesIndicator = tool.showExternalSourcesIndicator;
    } else if (typeof tool.show_external_sources_indicator === 'boolean') {
      showExternalSourcesIndicator = tool.show_external_sources_indicator;
    }

    sanitizedTools.push({
      name: rawName,
      description: rawDescription,
      instructions,
      parameters,
      showExternalSourcesIndicator,
    });
  });

  if (!sanitizedTools.length && tools.length > 0) {
    res.status(400).json({
      error: 'No valid tools provided',
      invalid_tools: invalidTools,
    });
    return;
  }

  agentManager.registerClientTools(channelIdNormalized, sanitizedTools);

  const responsePayload: Record<string, unknown> = {
    message: 'Client tools registered',
    channel_id: channelIdNormalized,
    count: sanitizedTools.length,
  };
  if (invalidTools.length) {
    responsePayload.invalid_tools = invalidTools;
  }

  res.json(responsePayload);
});

Client-side, you can call this endpoint with the following code:

func registerTools(channelId: String, tools: [ToolRegistrationPayload]) async throws {
    guard !tools.isEmpty else { return }
    try await executePostRequest(
        body: ToolRegistrationRequest(channelId: channelId, tools: tools),
        endpoint: "register-tools"
    )
}

private func executePostRequestWithResponse<RequestBody: Encodable>(body: RequestBody, endpoint: String) async throws -> Data {
    let url = URL(string: "\(baseURL)/\(endpoint)")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try jsonEncoder.encode(body)
    let (data, _) = try await urlSession.data(for: request)
    return data
}

struct ToolRegistrationRequest: Encodable {
    let channelId: String
    let tools: [ToolRegistrationPayload]

    enum CodingKeys: String, CodingKey {
        case channelId = "channel_id"
        case tools
    }
}

Then, for example, on app start you can perform the registration with the following code:

let tools = clientToolRegistry.registrationPayloads()
try await registerTools(channelId: "your_channel_id", tools: tools)
© Getstream.io, Inc. All Rights Reserved.