API Imperativa do WebMCP: document.modelContext em Detalhe

Visão geral da API Imperativa

A API Imperativa é o coração do WebMCP. Com ela, você registra tools via JavaScript puro usando document.modelContext — a interface que conecta sua página a agentes de IA.

Quando faz sentido usar a API Imperativa:

  • Lógica programática (buscas, cálculos, transformações)
  • Operações assíncronas (chamadas a APIs, banco de dados)
  • Controle fino sobre registro/desregistro de tools
  • Interação cross-origin entre documentos

Atenção: No Chrome 150+, a API migrou de navigator.modelContext para document.modelContext. Use o pattern de feature detection abaixo para funcionar nas duas versões.

const mc = document.modelContext ?? navigator.modelContext;
if (!mc?.registerTool) return; // Browser não suporta WebMCP

document.modelContext — A interface principal

document.modelContext é o ponto de entrada para tudo que é imperativo no WebMCP. Três métodos e um evento:

Método/EventoDescrição
registerTool(definition, options?)Registra uma tool no contexto
getTools(options?)Descobre tools disponíveis
executeTool(tool, args, options?)Executa uma tool manualmente
addEventListener('toolchange', handler)Escuta mudanças na lista de tools

registerTool() — Registrando tools

Assinatura

document.modelContext.registerTool(
  toolDefinition: ToolDefinition,
  options?: RegisterToolOptions
): void

ToolDefinition — Estrutura completa

interface ToolDefinition {
  name: string;                    // Identificador (max 30 chars recomendado)
  description: string;             // O que a tool faz (max 500 chars)
  inputSchema: JSONSchema;         // Schema dos parâmetros de entrada
  execute: (args: object) => Promise<any>;  // Callback executado
  annotations?: {
    readOnlyHint?: boolean;        // true = não altera estado
    untrustedContentHint?: boolean; // true = output pode conter UGC
  };
}

interface RegisterToolOptions {
  signal?: AbortSignal;            // Para desregistrar via abort
  exposedTo?: string[];            // Origins cross-origin permitidas
}

Exemplo básico — Tool de busca

const mc = document.modelContext ?? navigator.modelContext;

mc.registerTool({
  name: 'search_articles',
  description: 'Search published articles by keyword. Returns title, URL and excerpt.',
  inputSchema: {
    type: 'object',
    properties: {
      query: { 
        type: 'string', 
        description: 'Search term (case-insensitive)' 
      },
      limit: { 
        type: 'number', 
        description: 'Max results (default: 5, max: 20)' 
      }
    },
    required: ['query']
  },
  execute: async ({ query, limit = 5 }) => {
    const results = await fetchArticles(query, Math.min(limit, 20));
    return JSON.stringify(results.map(a => ({
      title: a.title,
      url: a.url,
      excerpt: a.excerpt.slice(0, 200)
    })));
  },
  annotations: {
    readOnlyHint: true,
    untrustedContentHint: true
  }
});

Exemplo com enum e oneOf

Quando os parâmetros têm valores fixos, enum combinado com oneOf dá contexto semântico ao agente:

mc.registerTool({
  name: 'get_order_status',
  description: 'Search orders in a given timeframe. Returns order number, shipping status and location.',
  inputSchema: {
    type: 'object',
    properties: {
      timeframe: {
        type: 'string',
        oneOf: [
          { type: 'string', const: 'today', title: 'Today' },
          { type: 'string', const: 'yesterday', title: 'Yesterday' },
          { type: 'string', const: 'last_7_days', title: 'Last 7 Days' },
          { type: 'string', const: 'last_30_days', title: 'Last 30 Days' },
          { type: 'string', const: 'last_6_months', title: 'Last 6 Months' }
        ],
        enum: ['today', 'yesterday', 'last_7_days', 'last_30_days', 'last_6_months'],
        description: 'Timeframe for the order lookup.'
      }
    },
    required: ['timeframe']
  },
  execute: async ({ timeframe }) => {
    const orders = await fetchOrders(timeframe);
    return JSON.stringify(orders);
  }
});

Dica: O title dentro de oneOf dá contexto humano para cada opção. Se o usuário diz “pedidos recentes”, o agente entende que last_7_days é a melhor escolha — justamente por causa do title.

Exemplo completo — Pizza Maker (demo oficial)

O padrão usado na demo oficial do Google Chrome Labs:

const controller = new AbortController();

mc.registerTool({
  name: 'toggle_layer',
  description: 'Control pizza layers (sauce, cheese). Use "add", "remove", or "toggle".',
  inputSchema: {
    type: 'object',
    properties: {
      layer: { 
        type: 'string', 
        enum: ['sauce-layer', 'cheese-layer'],
        description: 'Which layer to modify'
      },
      action: { 
        type: 'string', 
        enum: ['add', 'remove', 'toggle'],
        description: 'Action to perform on the layer'
      }
    },
    required: ['layer']
  },
  execute: async ({ layer, action = 'toggle' }) => {
    await toggleLayer(layer, action);
    return `Performed ${action} on layer: ${layer}`;
  },
  annotations: {
    readOnlyHint: false,
    untrustedContentHint: false
  }
}, { signal: controller.signal });

AbortSignal — Desregistrando tools

O jeito recomendado de desregistrar tools é via AbortController. Chamou controller.abort()? A tool some do registro. Simples assim.

Registro condicional baseado em estado

class CartManager {
  #controller = null;

  onCartUpdated(items) {
    // Desregistra tool anterior
    this.#controller?.abort();
    
    if (items.length > 0) {
      // Registra tool de checkout apenas quando há itens
      this.#controller = new AbortController();
      
      mc.registerTool({
        name: 'checkout',
        description: `Proceed to checkout with ${items.length} items. Total: R$${this.getTotal()}.`,
        inputSchema: {
          type: 'object',
          properties: {
            coupon: { 
              type: 'string', 
              description: 'Optional discount coupon code' 
            }
          }
        },
        execute: async ({ coupon }) => {
          const result = await processCheckout(coupon);
          return JSON.stringify(result);
        }
      }, { signal: this.#controller.signal });
    }
  }
}

Desregistro por navegação SPA

Em SPAs, registre/desregistre tools conforme o usuário navega:

// React example
function ProductPage({ productId }) {
  useEffect(() => {
    const controller = new AbortController();
    
    mc.registerTool({
      name: 'add_to_cart',
      description: `Add current product (ID: ${productId}) to shopping cart`,
      inputSchema: {
        type: 'object',
        properties: {
          quantity: { type: 'number', description: 'Quantity (default: 1)' }
        }
      },
      execute: async ({ quantity = 1 }) => {
        await addToCart(productId, quantity);
        return `Added ${quantity}x product ${productId} to cart`;
      }
    }, { signal: controller.signal });
    
    // Cleanup: desregistra quando componente desmonta
    return () => controller.abort();
  }, [productId]);
}

Dica: Em React, Vue ou Svelte, vincule o ciclo de vida da tool ao ciclo de vida do componente. O AbortController encaixa naturalmente nesse padrão — quem já trabalha com fetch já conhece a mecânica.


getTools() — Descobrindo tools disponíveis

Assinatura

document.modelContext.getTools(
  options?: GetToolsOptions
): Promise<ToolDescriptor[]>

interface GetToolsOptions {
  fromOrigins?: string[];  // Origins cross-origin para buscar
}

interface ToolDescriptor {
  name: string;
  description: string;
  inputSchema: string;          // JSON stringificado
  origin: string;               // Origin que registrou
  window: Window;               // Window do documento
  annotations?: {
    readOnlyHint?: boolean;
    untrustedContentHint?: boolean;
  };
}

Listando tools same-origin

const tools = await mc.getTools();

tools.forEach(tool => {
  console.log(`${tool.name}: ${tool.description}`);
  console.log(`  Schema: ${tool.inputSchema}`);
  console.log(`  Origin: ${tool.origin}`);
});

Listando tools cross-origin

Para acessar tools de outros origins, passe fromOrigins:

const allTools = await mc.getTools({
  fromOrigins: ['https://partner-widget.com', 'https://payments.example.com']
});

Nota: Uma tool cross-origin só aparece se duas condições forem verdadeiras: (1) a tool foi registrada com exposedTo incluindo seu origin, e (2) você listou o origin da tool em fromOrigins. Tem que bater dos dois lados — é um handshake explícito.


executeTool() — Execução manual de tools

Assinatura

document.modelContext.executeTool(
  tool: ToolDescriptor,
  argsJSON: string,
  options?: { signal?: AbortSignal }
): Promise<string | null>

O retorno é null quando a execução causa navegação (mudança de página).

Exemplo: Orquestração de tools

// Obter tools disponíveis
const tools = await mc.getTools();
const searchTool = tools.find(t => t.name === 'search_content');

if (searchTool) {
  // Executar com argumentos JSON
  const result = await mc.executeTool(
    searchTool,
    JSON.stringify({ query: 'WebMCP', limit: 3 })
  );
  
  console.log('Resultado:', result);
}

Execução com timeout via AbortSignal

const controller = new AbortController();

// Timeout de 10 segundos
const timeout = setTimeout(() => controller.abort(), 10000);

try {
  const result = await mc.executeTool(
    tool,
    JSON.stringify({ query: 'heavy search' }),
    { signal: controller.signal }
  );
  clearTimeout(timeout);
  return result;
} catch (error) {
  if (error.name === 'AbortError') {
    console.warn('Tool execution timed out');
    return null;
  }
  throw error;
}

Evento toolchange — Reagindo a mudanças

O evento toolchange dispara sempre que a lista de tools muda — registro, desregistro, ou mudança em tools de iframes.

mc.addEventListener('toolchange', async () => {
  // Re-descobrir tools disponíveis
  const updatedTools = await mc.getTools();
  console.log(`Tools atualizadas: ${updatedTools.length} disponíveis`);
  
  // Atualizar UI se necessário
  updateToolsPanel(updatedTools);
});

Caso de uso: Page Agent

Um padrão comum é construir um “page agent” que monitora tools e apresenta ao usuário:

class PageAgent {
  #tools = [];
  
  constructor() {
    mc.addEventListener('toolchange', () => this.refresh());
    this.refresh();
  }
  
  async refresh() {
    this.#tools = await mc.getTools({
      fromOrigins: ['https://trusted-widget.com']
    });
    this.render();
  }
  
  render() {
    const panel = document.getElementById('agent-panel');
    panel.innerHTML = this.#tools.map(tool => `
      <div class="tool-card">
        <h3>${tool.name}</h3>
        <p>${tool.description}</p>
        <code>${tool.inputSchema}</code>
      </div>
    `).join('');
  }
}

Cross-origin — Compartilhando tools entre domínios

Cenário

Um e-commerce (shop.example.com) embarca um widget de pagamento (payments.provider.com) via iframe. O widget quer expor uma tool process_payment para o site pai.

Passo 1: Widget registra tool com exposedTo

// Rodando em payments.provider.com (dentro do iframe)
mc.registerTool({
  name: 'process_payment',
  description: 'Process a payment with the given amount and method',
  inputSchema: {
    type: 'object',
    properties: {
      amount: { type: 'number', description: 'Amount in cents' },
      method: { type: 'string', enum: ['credit', 'debit', 'pix'] }
    },
    required: ['amount', 'method']
  },
  execute: async ({ amount, method }) => {
    const result = await processPayment(amount, method);
    return JSON.stringify({ transactionId: result.id, status: result.status });
  }
}, {
  exposedTo: ['https://shop.example.com']  // Apenas este origin pode ver
});

Passo 2: Site pai habilita Permissions Policy no iframe

<!-- Em shop.example.com -->
<iframe 
  src="https://payments.provider.com/widget" 
  allow="tools"
></iframe>

Passo 3: Site pai consulta tools cross-origin

// Rodando em shop.example.com
const tools = await mc.getTools({
  fromOrigins: ['https://payments.provider.com']
});

const paymentTool = tools.find(t => t.name === 'process_payment');
// paymentTool agora está disponível para o agente

Regras de visibilidade cross-origin

CondiçãoResultado
Tool sem exposedToVisível apenas para same-origin e agentes built-in
Tool com exposedTo: ['https://A.com']Visível para A.com se A.com usar fromOrigins
fromOrigins sem match em exposedToTool não aparece
iframe sem allow="tools"Tool não pode ser registrada

Atenção: Apenas secure origins (HTTPS) são aceitas em exposedTo e fromOrigins. HTTP localhost é a exceção para desenvolvimento.


Patterns avançados

Tool com validação e retry amigável

mc.registerTool({
  name: 'create_event',
  description: 'Create a calendar event. Date must be in the future.',
  inputSchema: {
    type: 'object',
    properties: {
      title: { type: 'string', description: 'Event title' },
      date: { type: 'string', description: 'Date in ISO 8601 format (YYYY-MM-DD)' },
      time: { type: 'string', description: 'Time as provided by user (e.g. "3pm", "15:00")' }
    },
    required: ['title', 'date']
  },
  execute: async ({ title, date, time }) => {
    const eventDate = new Date(date);
    
    if (isNaN(eventDate.getTime())) {
      return JSON.stringify({ 
        error: 'Invalid date format. Please use YYYY-MM-DD.',
        example: '2026-07-15'
      });
    }
    
    if (eventDate < new Date()) {
      return JSON.stringify({ 
        error: 'Date must be in the future.',
        today: new Date().toISOString().split('T')[0]
      });
    }
    
    const event = await calendar.createEvent({ title, date, time });
    return JSON.stringify({ 
      success: true, 
      eventId: event.id,
      message: `Event "${title}" created for ${date}` 
    });
  }
});

Dica: Retorne erros descritivos em JSON — o agente usa essa informação para corrigir o input e tentar de novo. Exceções genéricas não ajudam ninguém.

Tool com rate limiting graceful

const rateLimiter = {
  calls: 0,
  resetAt: Date.now() + 60000,
  maxCalls: 10,
  
  canCall() {
    if (Date.now() > this.resetAt) {
      this.calls = 0;
      this.resetAt = Date.now() + 60000;
    }
    return this.calls < this.maxCalls;
  }
};

mc.registerTool({
  name: 'search_products',
  description: 'Search product catalog by keyword.',
  inputSchema: {
    type: 'object',
    properties: {
      query: { type: 'string', description: 'Search term' }
    },
    required: ['query']
  },
  execute: async ({ query }) => {
    if (!rateLimiter.canCall()) {
      return JSON.stringify({
        error: 'Rate limit reached. Try again in 1 minute.',
        suggestion: 'User can search manually using the search bar above.'
      });
    }
    
    rateLimiter.calls++;
    const results = await searchProducts(query);
    return JSON.stringify(results.slice(0, 5));
  },
  annotations: { readOnlyHint: true, untrustedContentHint: true }
});

Registro dinâmico baseado em autenticação

async function registerAuthenticatedTools() {
  const controller = new AbortController();
  const user = await getCurrentUser();
  
  if (!user) {
    // Usuário não autenticado: apenas tools públicas
    mc.registerTool({
      name: 'login',
      description: 'Navigate to login page',
      inputSchema: { type: 'object', properties: {} },
      execute: async () => {
        window.location.href = '/login';
        return null; // navegação
      }
    }, { signal: controller.signal });
    return controller;
  }
  
  // Usuário autenticado: tools completas
  mc.registerTool({
    name: 'get_my_orders',
    description: 'List my recent orders with status and tracking',
    inputSchema: {
      type: 'object',
      properties: {
        status: { 
          type: 'string', 
          enum: ['all', 'pending', 'shipped', 'delivered'],
          description: 'Filter by order status'
        }
      }
    },
    execute: async ({ status = 'all' }) => {
      const orders = await fetchUserOrders(user.id, status);
      return JSON.stringify(orders.slice(0, 10));
    },
    annotations: { readOnlyHint: true }
  }, { signal: controller.signal });
  
  mc.registerTool({
    name: 'update_profile',
    description: 'Update user profile information',
    inputSchema: {
      type: 'object',
      properties: {
        name: { type: 'string', description: 'Full name' },
        email: { type: 'string', description: 'Email address' },
        phone: { type: 'string', description: 'Phone number' }
      }
    },
    execute: async (updates) => {
      const result = await updateProfile(user.id, updates);
      return JSON.stringify({ success: true, updated: Object.keys(updates) });
    },
    annotations: { readOnlyHint: false }
  }, { signal: controller.signal });
  
  return controller;
}

Angular: Suporte experimental

Angular tem suporte experimental para WebMCP com integração ao ciclo de vida do dependency injection:

  • Tools vinculadas ao lifecycle do DI — desregistram automaticamente quando o componente é destruído
  • Signal Forms transformadas em WebMCP tools automaticamente
  • Integração nativa com o sistema de reatividade do Angular

Character budgets

O Chrome recomenda limites para que tools não sobrecarreguem a context window do agente:

ElementoLimite recomendado
Nome da tool30 caracteres
Descrição da tool500 caracteres
Nome de parâmetro30 caracteres
Descrição de parâmetro150 caracteres
Output da tool1.500 caracteres

Nota: São recomendações, não enforcement obrigatório (ainda). Mas respeite — garante compatibilidade com qualquer agente que consumir suas tools.


Checklist de implementação

Antes de publicar suas tools em produção:

  • Feature detection com fallback (document.modelContext ?? navigator.modelContext)
  • Nomes claros e descritivos (verbos de ação)
  • Schemas com required apenas para campos obrigatórios
  • annotations.readOnlyHint definido corretamente
  • annotations.untrustedContentHint para outputs com UGC
  • Output truncado dentro do character budget (1.500 chars)
  • Erros descritivos em JSON (não exceções genéricas)
  • AbortController para cleanup em SPAs
  • Cross-origin com exposedTo apenas para origins confiáveis
  • Testado com Model Context Tool Inspector

Próximos passos