Skip to main content

Wrapper Pattern Guide

Build an MCP canister that wraps existing backend logic, providing AI agents with high-level tools and identity management.

What is the Wrapper Pattern?

The wrapper pattern creates an MCP server canister that acts as an identity-owning interface to your existing backend canister(s). It orchestrates complex multi-step workflows so AI agents don’t have to. When to Use:
  • ✅ Complex workflows (DEX swaps, multi-step approvals)
  • ✅ Need canister identity to hold allowances
  • ✅ Wrapping existing protocols (DeFi, governance)
  • ❌ Simple CRUD operations (use multi-tenant instead)

Example: DEX Swap Wrapper

Let’s wrap a DEX canister to provide a simple “swap” tool that handles all the complexity.

What the User Wants

"Swap 10 ICP for ckBTC"

What the Wrapper Does

// Behind the scenes, the wrapper orchestrates:
// 1. Check wrapper's ICP balance
// 2. If insufficient, pull ICP from user's wallet
// 3. Approve DEX to spend ICP
// 4. Get swap quote
// 5. Execute swap
// 6. Return ckBTC to user

Step-by-Step Implementation

1. Set Up Your Project

# Clone the ArcSwap template
git clone https://github.com/jneums/arcswap.git my-wrapper
cd my-wrapper

# Install dependencies
npm install
npm run mops:install

2. Define Your Tool Context

// src/tools/ToolContext.mo
module {
  public type ToolContext = {
    canisterPrincipal: Principal;  // This canister's identity
    owner: Principal;               // Authorized user
    dexCanisterId: Principal;       // Backend DEX
    icpLedgerId: Principal;         // ICP ledger
    ckBtcLedgerId: Principal;       // ckBTC ledger
  };
};

3. Create Your Swap Tool

// src/tools/dex_swap.mo
import McpTypes "mo:mcp-motoko-sdk/mcp/Types";
import AuthTypes "mo:mcp-motoko-sdk/auth/Types";
import Result "mo:base/Result";
import Principal "mo:base/Principal";

module {
  // Define the tool schema
  public func config() : McpTypes.Tool = {
    name = "dex_swap";
    title = ?"Execute DEX Swap";
    description = ?"Swap tokens using the DEX";
    payment = null;
    inputSchema = Json.obj([
      ("type", Json.str("object")),
      ("properties", Json.obj([
        ("fromToken", Json.obj([
          ("type", Json.str("string")),
          ("description", Json.str("Token to swap from (e.g., 'ICP')"))
        ])),
        ("amount", Json.obj([
          ("type", Json.str("string")),
          ("description", Json.str("Amount to swap"))
        ])),
        ("toToken", Json.obj([
          ("type", Json.str("string")),
          ("description", Json.str("Token to swap to (e.g., 'ckBTC')"))
        ])),
        ("userWallet", Json.obj([
          ("type", Json.str("string")),
          ("description", Json.str("User's wallet principal"))
        ]))
      ])),
      ("required", Json.arr([
        Json.str("fromToken"),
        Json.str("amount"),
        Json.str("toToken"),
        Json.str("userWallet")
      ]))
    ]);
    outputSchema = ?Json.obj([
      ("type", Json.str("object")),
      ("properties", Json.obj([
        ("txId", Json.obj([("type", Json.str("string"))])),
        ("amountOut", Json.obj([("type", Json.str("string"))])),
        ("status", Json.obj([("type", Json.str("string"))]))
      ]))
    ]);
  };

  // Implement the tool handler
  public func handle(
    context: ToolContext.ToolContext
  ) : (
    McpTypes.JsonValue,
    ?AuthTypes.AuthInfo,
    (Result.Result<McpTypes.CallToolResult, McpTypes.HandlerError>) -> ()
  ) -> async () {

    func(args, auth, cb) : async () {
      // Helper functions
      func makeError(message: Text) {
        cb(#ok({
          content = [#text({ text = message })];
          isError = true;
          structuredContent = null
        }));
      };

      func ok(structured: Json.Json) {
        cb(#ok({
          content = [#text({ text = Json.stringify(structured, null) })];
          isError = false;
          structuredContent = ?structured
        }));
      };

      // 1. Check authorization
      let ownerPrincipal = switch (auth) {
        case (?authInfo) authInfo.principal;
        case (null) { return makeError("Authentication required") };
      };

      if (ownerPrincipal != context.owner) {
        return makeError("Unauthorized: Only owner can execute swaps");
      };

      // 2. Parse inputs
      let amount = switch (Json.getAsText(args, "amount")) {
        case (#ok a) {
          switch (Nat.fromText(a)) {
            case (?n) n;
            case (null) { return makeError("Invalid amount") };
          }
        };
        case _ { return makeError("Missing amount") };
      };

      let userWallet = switch (Json.getAsText(args, "userWallet")) {
        case (#ok w) {
          switch (Principal.fromText(w)) {
            case (p) p;
            case (_) { return makeError("Invalid wallet principal") };
          }
        };
        case _ { return makeError("Missing userWallet") };
      };

      // 3. Check wrapper's balance
      let icpLedger = actor(Principal.toText(context.icpLedgerId)) : ICRC1.Self;
      let wrapperBalance = await icpLedger.icrc1_balance_of({
        owner = context.canisterPrincipal;
        subaccount = null;
      });

      let fee = 10_000; // ICP fee
      let totalNeeded = amount + fee;

      // 4. Pull funds if needed
      if (wrapperBalance < totalNeeded) {
        let amountToPull = totalNeeded - wrapperBalance;

        let transferResult = await icpLedger.icrc2_transfer_from({
          from = { owner = userWallet; subaccount = null };
          to = { owner = context.canisterPrincipal; subaccount = null };
          amount = amountToPull;
          fee = ?fee;
          memo = null;
          created_at_time = null;
        });

        switch (transferResult) {
          case (#Ok(_)) {};
          case (#Err(e)) {
            return makeError("Failed to pull funds: " # debug_show(e));
          };
        };
      };

      // 5. Approve DEX
      let approveResult = await icpLedger.icrc2_approve({
        spender = { owner = context.dexCanisterId; subaccount = null };
        amount = amount;
        expires_at = ?(Time.now() + 300_000_000_000); // 5 min expiry
        fee = ?fee;
        memo = null;
        created_at_time = null;
      });

      switch (approveResult) {
        case (#Ok(_)) {};
        case (#Err(e)) {
          return makeError("Failed to approve DEX: " # debug_show(e));
        };
      };

      // 6. Execute swap
      let dex = actor(Principal.toText(context.dexCanisterId)) : DEX.Self;
      let swapResult = await dex.swap({
        fromToken = "ICP";
        toToken = "ckBTC";
        amount = amount;
        slippage = 0.5; // 0.5%
      });

      switch (swapResult) {
        case (#Ok(result)) {
          let output = Json.obj([
            ("txId", Json.str(Nat.toText(result.txId))),
            ("amountOut", Json.str(Nat.toText(result.amountOut))),
            ("status", Json.str("success"))
          ]);
          ok(output);
        };
        case (#Err(e)) {
          makeError("Swap failed: " # e);
        };
      };
    };
  };
};

4. Register in Main Actor

// src/main.mo
import Mcp "mo:mcp-motoko-sdk/mcp/Mcp";
import McpTypes "mo:mcp-motoko-sdk/mcp/Types";
import DexSwap "tools/dex_swap";

shared ({ caller = deployer }) actor class WrapperMCP() = self {
  var owner: Principal = deployer;

  // Initialize tool context
  let toolContext: ToolContext.ToolContext = {
    canisterPrincipal = Principal.fromActor(self);
    owner = owner;
    dexCanisterId = Principal.fromText("your-dex-canister-id");
    icpLedgerId = Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai");
    ckBtcLedgerId = Principal.fromText("mxzaz-hqaaa-aaaar-qaada-cai");
  };

  // Register tools
  let tools: [McpTypes.Tool] = [
    DexSwap.config(),
  ];

  let mcpConfig: McpTypes.McpConfig = {
    self = Principal.fromActor(self);
    serverInfo = {
      name = "io.example.dex-wrapper";
      title = "DEX Swap Wrapper";
      version = "1.0.0";
    };
    tools = tools;
    toolImplementations = [
      ("dex_swap", DexSwap.handle(toolContext)),
    ];
    // ... rest of config
  };

  let mcpServer = Mcp.createServer(mcpConfig);

  // HTTP handlers
  public query func http_request(req: HttpTypes.HttpRequest) : async HttpTypes.HttpResponse {
    // ... HttpHandler.http_request
  };

  public shared func http_request_update(req: HttpTypes.HttpRequest) : async HttpTypes.HttpResponse {
    // ... HttpHandler.http_request_update
  };
};

Key Security Principles

1. Owner-Only Operations

// ✅ CORRECT - Check that caller is the authorized owner
if (ownerPrincipal != context.owner) {
  return makeError("Unauthorized");
};

// ❌ WRONG - Allowing any authenticated user
if (ownerPrincipal == null) {
  return makeError("Authentication required");
};
// Missing ownership check!

2. Use Allowances, Not Direct Transfers

// ✅ CORRECT - Pull exact amount needed via allowance
let result = await ledger.icrc2_transfer_from({
  from = { owner = userWallet; subaccount = null };
  to = { owner = context.canisterPrincipal; subaccount = null };
  amount = amountNeeded;
  // ...
});

// ❌ WRONG - Asking user to send funds first
// "Please send tokens to this canister, then call the swap function"
// This is dangerous and bad UX

3. Set Expiry Times

// ✅ CORRECT - Approvals should expire
expires_at = ?(Time.now() + 300_000_000_000); // 5 minutes

// ❌ WRONG - Infinite approval
expires_at = null; // Never expires - security risk!

4. Don’t Store User Funds

// ✅ CORRECT - Pull, use, and return in same operation
// Pull funds → Approve → Execute → Return remainder

// ❌ WRONG - Storing user funds long-term
stable var userBalances = Map.new<Principal, Nat>();
// This creates custody responsibilities

Testing Your Wrapper

1. Deploy Locally

dfx start --background
dfx deploy

2. Create API Key

dfx canister call wrapper_mcp create_my_api_key '("Test Key", vec {})'
# Save the returned key

3. Test the Swap

npm run inspector
Then connect to your local canister and test the tools interactively.

Common Patterns

Pattern 1: Multi-Step Workflows

// Check → Pull → Approve → Execute → Return
func orchestrateSwap() : async Result<SwapResult, Text> {
  let balance = await checkBalance();
  if (balance < needed) {
    let pulled = await pullFunds(needed - balance);
  };
  let approved = await approve(dex, amount);
  let swapped = await executeSwap(params);
  let returned = await returnRemainder();
  #ok(swapped)
};

Pattern 2: Quote Before Execute

// Tool 1: Get Quote (query)
public func dex_quote(...) : async Result<Quote, Text> {
  let dex = actor(...) : DEX.Self;
  await dex.get_quote(fromToken, toToken, amount)
};

// Tool 2: Execute with Quote (update)
public func dex_swap(quoteId: Text, ...) : async Result<TxId, Text> {
  // User reviews quote, then executes
};

Pattern 3: Claim Rewards

// Wrapper checks rewards and claims on behalf of user
public func claim_rewards(...) : async Result<Nat, Text> {
  let rewards = await protocol.check_rewards(userWallet);
  if (rewards > 0) {
    await protocol.claim_to(userWallet);
    #ok(rewards)
  } else {
    #err("No rewards available")
  }
};

Comparison with Multi-Tenant Pattern

FeatureWrapper PatternMulti-Tenant Pattern
PurposeOrchestrate complex workflowsStore user data
IdentityCanister holds allowancesUsers identified by principal
Use CasesDEX swaps, DeFi, governanceTodo lists, notes, settings
ComplexityHigh (multi-step operations)Low (CRUD operations)
BackendWraps existing protocolsSelf-contained logic

Next Steps

  • For simple data storage: See the Multi-Tenant Pattern Guide
  • Deploy to production: Use dfx deploy --network ic
  • Add to App Store: Submit via prometheus submit
I