Heimdall MCP
Proxy MCP transparente com rastreamento OpenTelemetry. Envolva qualquer servidor MCP, persista rastreamentos em qualquer backend OTel · SQLite · Postgres · MySQL. Nenhuma alteração de código necessária.
Documentação
@cardor/heimdall-mcp
Proxy transparente para qualquer servidor MCP. Intercepta todas as mensagens JSON-RPC, mede latência, armazena rastros em um banco de dados configurável e aplica políticas de permitir/negar por servidor — sem tocar no servidor original.
Visite o site para ver uma explicação completa, exemplos e outras ferramentas!
Sumário
- @cardor/heimdall-mcp
Como funciona
flowchart LR
A["MCP Client\n(Claude Desktop / OpenCode / Cursor)"]
subgraph proxy["heimdall-mcp"]
B["TelemetryInterceptor"]
P["PolicyInterceptor"]
C["ForwardInterceptor"]
D[("SQLite\nPostgres\nMySQL")]
B --> P
P --> C
B -->|"saves span"| D
end
S["Real MCP server\n(subprocess / HTTP / SSE)"]
A -->|"stdio"| B
C -->|"stdio · http · sse"| S
S -->|"response"| C
C -->|"response"| A
O proxy sempre expõe stdio ao cliente MCP e fala o transporte correto com o servidor real. Cada par de requisição/resposta é convertido em um span com tempo, atributos e o corpo de entrada/saída.
Instalação
npm install -g @cardor/heimdall-mcp
# or as a project dependency
npm install @cardor/heimdall-mcp
Configuração de políticas
Coloque um heimdall.config.ts na raiz do seu projeto e defina exatamente quais ferramentas, prompts e recursos cada servidor MCP pode expor ao agente — na camada de proxy, sem tocar no código do servidor.
Arquivos de configuração
| Escopo | Caminho | Finalidade |
|---|---|---|
| Local | {project-root}/heimdall.config.{ts,js,mjs,cjs,json} | Regras por repositório |
| Global | ~/.config/heimdall/heimdall.config.{ts,js,mjs,cjs,json} | Regras para usuário/organização |
Ambos são opcionais. Se nenhum existir, o proxy permanece totalmente transparente (compatível com versões anteriores).
Formato de configuração
// heimdall.config.ts
import type { HeimdallConfig } from '@cardor/heimdall-mcp';
export default {
// default: applies to any server without an explicit entry
default: {
tools: { allow: ['*'], deny: [] },
},
// servers: keyed by --server-name (or serverInfo.name from initialize response)
servers: {
filesystem: {
tools: {
allow: ['read_file', 'list_directory', 'search_files'],
deny: ['write_file', 'create_file', 'delete_file', 'move_file'],
},
resources: {
allow: ['*'],
deny: ['file:///etc/*', 'file:///root/*'],
},
},
database: {
tools: {
allow: ['query', 'describe_table', 'list_tables'],
deny: ['execute', 'drop_table', 'truncate'],
},
},
},
} satisfies HeimdallConfig;
Configurações TypeScript são carregadas via jiti sem pré-compilação. Também funciona como .js, .mjs, .cjs ou .json.
Estratégia de mesclagem
Quando as configurações local e global existem, elas são mescladas com semântica de segurança em primeiro lugar:
| Regra | Comportamento |
|---|---|
| Negar → união | Negado por qualquer um = negado. A negação global não pode ser sobrescrita localmente. |
| Permitir → interseção | Deve passar em ambas. * ou [] significa "deferir para o outro lado". |
| Negar vence permitir | Dentro de qualquer configuração individual, negar sempre vence. |
A configuração global impõe um piso que a equipe não pode afrouxar acidentalmente. Configurações locais só podem adicionar mais restrições, nunca menos.
Políticas de argumentos
toolPolicies adiciona uma segunda camada de aplicação sobre as regras de nível de nome tools. Em vez de apenas decidir quais ferramentas são chamáveis, você pode restringir quais argumentos são permitidos em cada chamada.
// heimdall.config.ts
export default {
servers: {
filesystem: {
tools: { allow: ['read_file', 'list_directory'] },
toolPolicies: {
// '*' applies to every tool (merged first; tool-specific entries override)
'*': {
args: {
path: { isPath: true, deny_pattern: ['\\.env$', '\\.pem$'] },
},
},
read_file: {
args: {
// scope path to the current working directory
path: { isPath: true, allow_pattern: './' },
// allow only safe encodings
encoding: { allow_pattern: ['utf-8', 'utf8', 'ascii'] },
},
},
},
},
},
} satisfies HeimdallConfig;
Campos de ArgConstraint
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
isPath | boolean | false | Habilita correspondência ciente de caminho (verificação de contenção) em vez de regex |
allow_pattern | string | string[] | — | O argumento deve corresponder a pelo menos um padrão para passar |
deny_pattern | string | string[] | — | O argumento é bloqueado se corresponder a qualquer padrão; negar vence permitir |
array_mode | 'all' | 'any' | 'all' | Para argumentos do tipo array: exige que todos os itens passem (all) ou pelo menos um (any) |
case_sensitive | boolean | true | Sinalizador de regex; não aplicado à correspondência de raiz de caminho |
warn_only | boolean | false | Registra a violação no span OTel sem bloquear a chamada |
Escopo de caminho com isPath: true
Quando isPath: true, padrões que parecem raízes de diretório são tratados como verificações de contenção em vez de expressões regex:
| Padrão | Significado |
|---|---|
"./" ou "." | O argumento deve resolver dentro de process.cwd() |
"/some/dir" | O argumento deve resolver dentro de /some/dir |
"~" / "${HOME}/projects" | Resolvido para o diretório home |
"${CWD}/data" | Resolvido para cwd + /data |
O resolvedor usa path.resolve + fs.realpathSync para evitar travessia de ../ e escapes de symlink. Padrões que não parecem raízes de diretório (ex.: "^/etc/.*") voltam para correspondência regex.
Modo warn_only
Útil para implantação gradual: defina warn_only: true para observar violações sem bloquear. A chamada é encaminhada e os seguintes atributos aparecem no span OTel:
policy.arg_warning = true
policy.arg_warning_field = "path"
policy.arg_warning_message = "Tool arg 'path' is denied by policy"
Mude para warn_only: false (o padrão) quando estiver pronto para aplicar.
Notação de ponto para argumentos aninhados
Use notação de ponto para restringir campos dentro de objetos de parâmetros aninhados:
toolPolicies: {
my_tool: {
args: {
'options.target': { isPath: true, allow_pattern: './' },
},
},
}
Bloqueios de recursos
Evita que invocações concorrentes de tools/call disputem o mesmo recurso (ex.: dois agentes escrevendo o mesmo arquivo ao mesmo tempo). Configure um bloco locks por servidor, chaveado pelo nome da ferramenta.
Casos de uso
Cenários concretos onde bloqueios de recursos resolvem um problema real de coordenação:
- Dois agentes de IA de codificação editando o mesmo arquivo simultaneamente. Duas sessões de agente (ex.: duas instâncias do Claude Code, ou um servidor de sistema de arquivos MCP invocado por múltiplos agentes) rodando em paralelo contra o mesmo repositório podem decidir escrever o mesmo arquivo ao mesmo tempo. Sem um bloqueio, a segunda escrita sobrescreve silenciosamente as alterações do primeiro agente. Bloquear no argumento de caminho do arquivo (
resource: 'path'ouresource: 'file_path') serializa essas chamadas — a segunda chamada é rejeitada (ou, comonConflict: 'warn', encaminhada com um aviso) até que a primeira termine ou o TTL do bloqueio expire. - Serializando uma ferramenta de migração de banco de dados entre sessões paralelas. Uma ferramenta
run_migrationexposta através de um servidor de banco de dados MCP é perigosa de executar concorrentemente — duas migrações sobrepostas contra o mesmo banco podem corromper o estado. Bloquear em uma chave de recurso literal (resource: 'db-migration', não vinculada a nenhum argumento específico) garante que apenas uma chamada de migração esteja em andamento por vez, independentemente de qual sessão ou agente a acionou. - Evitando chamadas duplicadas/em disputa a uma API externa com limite de taxa. Uma ferramenta que chama uma API de terceiros com limite de taxa estrito (ex.: APIs de pagamento ou busca) pode ser bloqueada em uma chave estável (o nome da ferramenta, ou um argumento como
query) para que chamadas duplicadas quase simultâneas de turnos separados de agentes não multipliquem o uso da API ou disparem o limitador de taxa do provedor. - Coordenando entre múltiplas instâncias de proxy, não apenas um processo. Com um armazenamento de bloqueio Postgres/MySQL compartilhado (veja abaixo) em vez do arquivo SQLite local padrão, os bloqueios de recursos coordenam entre máquinas — útil para frotas de agentes ou instâncias de proxy compartilhando a mesma infraestrutura de suporte.
// heimdall.config.ts
export default {
servers: {
filesystem: {
tools: { allow: ['read_file', 'write_file'] },
locks: {
write_file: { resource: 'path', ttl: 30_000 },
run_migration: { resource: 'db-migration', onConflict: 'warn' },
},
},
},
} satisfies HeimdallConfig;
Campos de LockRule
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
resource | string | — | Nome de um argumento de chamada de ferramenta cujo valor é usado como chave de bloqueio (ex.: resource: 'path' bloqueia em arguments.path), ou uma chave de recurso literal (ex.: resource: 'db-migration') se nenhum argumento com esse nome existir. Volta para o nome da própria ferramenta quando omitido. Valores semelhantes a caminhos são canonicalizados automaticamente (veja abaixo). |
ttl | number | 30000 | Tempo de vida do bloqueio em milissegundos. Atua como uma salvaguarda para que um titular travado/parado não possa manter um recurso bloqueado para sempre. |
onConflict | 'reject' | 'warn' | 'reject' | reject bloqueia a chamada e retorna um erro JSON-RPC RESOURCE_LOCKED (padrão). warn encaminha a chamada mesmo assim e anexa metadados de aviso lock.* ao span OTel em vez de bloquear. |
Semântica: modo de escrita, TTL e modo de leitura
- O modo de escrita é exclusivo. Todo bloqueio adquirido por
LockInterceptoré um bloqueio'write': no máximo um titular pode segurar uma determinada chave de recurso por vez, independentemente de a chamada subjacente ser conceitualmente uma leitura ou uma escrita. Não há modo compartilhado/concorrente separado — duas chamadas que apenas precisam ler o mesmo recurso ainda se serializam entre si se compartilharem uma regra de bloqueio. - O modo
'read'existe como tipo, não como recurso. A interfaceLockStoredefineLockMode = 'read' | 'write'na camada de armazenamento, masLockInterceptoratualmente codifica'write'em toda chamadaacquire(), eLockRuleSchemanão tem campomodepara selecioná-lo a partir deheimdall.config.ts. Não confie no modo'read'para nada — ele ainda não é configurável nem acessível a partir da configuração do usuário. - O que a expiração do TTL significa na prática.
ttlnão é um timeout por chamada — é uma salvaguarda para titulares travados. Se o processo segurando um bloqueio travar, pendurar ou for morto antes de liberar o bloqueio, o bloqueio bloquearia esse recurso para sempre. Uma vez quettlmilissegundos passem desde a aquisição, o bloqueio é tratado como expirado e um novo chamador pode adquirir o mesmo recurso, mesmo que o titular original nunca o tenha liberado explicitamente. A liberação é idempotente, então se o titular original (travado) chamarrelease()depois que seu bloqueio já expirou e foi readquirido por outra pessoa, essa liberação obsoleta é um no-op silencioso — ela não libera o bloqueio do novo titular.
Status: o bloqueio em modo de escrita (exclusivo) é aplicado em tools/call — LockInterceptor adquire um bloqueio antes de encaminhar, libera-o na conclusão ou erro, e é respaldado por um LockStore. Chaves de recurso que parecem caminhos de sistema de arquivos (absolutos, ~, ./, ../ ou uma letra de unidade) são canonicalizadas — expandidas, resolvidas para um caminho absoluto e symlinks seguidos — para que o mesmo arquivo real seja bloqueado consistentemente, não importa como seja referenciado (caminho relativo, ~, symlink ou um diretório de projeto diferente). Ainda não implementado: bloqueio em modo de leitura (todo bloqueio é atualmente adquirido em modo exclusivo 'write'; não há campo mode em LockRuleSchema ainda — veja Limitações).
Por padrão, o armazenamento de bloqueio é um arquivo SQLite local em ~/.config/heimdall/locks.db — zero configuração necessária. Backends Postgres e MySQL também estão disponíveis para coordenação de bloqueio entre múltiplas máquinas (ex.: múltiplas instâncias de proxy compartilhando os mesmos bloqueios de recursos), via --lock-store:
heimdall-mcp --store sqlite://./traces.db --lock-store postgres://user:pass@host/db -- node server.js
heimdall-mcp --store sqlite://./traces.db --lock-store mysql://user:pass@host/db -- node server.js
Políticas de host
servers e default configuram servidores MCP roteados através do pipeline tools/call do proxy. hosts é um campo separado, irmão de nível superior, para configurar a política de bloqueio em ferramentas nativas do host — ferramentas integradas ao próprio agente de codificação (ex.: Write/Edit do Claude Code, ou equivalentes do OpenCode/Codex) que não são servidores MCP e não são interceptadas pelo proxy JSON-RPC. Ele é indexado por nome do host, e o bloco locks de cada host usa exatamente a mesma forma LockRule (resource/ttl/onConflict) documentada acima.
// heimdall.config.ts
export default {
hosts: {
'claude-code': {
locks: {
Write: { resource: 'file_path', ttl: 30_000 },
Edit: { resource: 'file_path', ttl: 30_000 },
MultiEdit: { resource: 'file_path', ttl: 30_000 },
NotebookEdit: { resource: 'file_path', ttl: 30_000 },
},
},
},
} satisfies HeimdallConfig;
Campos de HostPolicy
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
locks | Record<string, LockRule> | — | Mesma forma que o bloco locks de um servidor, indexado por nome da ferramenta nativa (ex.: Write, Edit). Veja Campos de LockRule acima. |
@cardor/heimdall-mcp exporta CLAUDE_CODE_DEFAULT_HOST_POLICY, um HostPolicy pronto que cobre as ferramentas nativas de mutação de arquivos do Claude Code — Write, Edit, MultiEdit e NotebookEdit — cada uma bloqueando em um argumento file_path com TTL de 30s.
Ainda não implementado: Bash é deliberadamente excluído de CLAUDE_CODE_DEFAULT_HOST_POLICY e não possui regra de bloqueio recomendada. Comandos shell arbitrários do Bash não têm um único argumento "recurso" estável para bloquear — um comando poderia tocar zero, um ou muitos arquivos — então bloqueá-lo seria ou sem sentido (sem chave de recurso para extrair) ou perigosamente grosseiro (serializando todas as chamadas Bash globalmente, incluindo trabalho não relacionado).
Status: carregamento de configuração, mesclagem e aplicação via um script de hook PreToolUse real do Claude Code. O campo hosts, HostPolicySchema e CLAUDE_CODE_DEFAULT_HOST_POLICY são definidos, validados e mesclados (o hosts da configuração global e o hosts da configuração local são combinados por chave de host, com o locks local para um determinado host vencendo inteiramente sobre o global quando ambos o definem — sem união em nível de campo, correspondendo à semântica default/servers locks). Se nenhuma configuração definir hosts['claude-code'], CLAUDE_CODE_DEFAULT_HOST_POLICY é aplicado automaticamente como linha de base; qualquer valor hosts['claude-code'] fornecido pelo usuário (em qualquer configuração) substitui o padrão inteiramente.
Hook PreToolUse do Claude Code
bin/hooks/claude-pretooluse.js é um script de hook PreToolUse real e funcional para o Claude Code. Em cada invocação, ele lê tool_name/tool_input da entrada padrão, recarrega heimdall.config.* fresco do disco (local + global, mesclado — nunca em cache), corresponde a ferramenta contra hosts['claude-code'].locks, resolve o recurso de bloqueio a partir do argumento resource da regra correspondente (canonicalizando caminhos de sistema de arquivos da mesma forma que a resolução de recurso LockRule faz em outros lugares deste documento) e tenta adquirir um bloqueio exclusivo contra o mesmo armazenamento de bloqueio SQLite padrão usado pelo proxy (~/.config/heimdall/locks.db). Se o bloqueio estiver livre, a chamada é permitida. Se estiver mantido por outro titular, a chamada é negada com um motivo legível por humanos. O hook sempre sai com 0 e falha aberto (permite silenciosamente) em qualquer erro inesperado — um bug no hook nunca deve travar uma sessão do Claude Code.
Para a investigação completa do contrato do hook PreToolUse e os casos de borda não resolvidos por trás desta implementação, veja SPIKE_CLAUDE_HOOKS.md.
Recomendado: execute heimdall-mcp init --hooks claude-code para registrar este hook automaticamente:
heimdall-mcp init --hooks claude-code
Isso lê ~/.claude/settings.json (criando-o se não existir), resolve o caminho absoluto para o bin/hooks/claude-pretooluse.js do pacote instalado e anexa uma entrada PreToolUse para ele — sem tocar em outras entradas hooks.* ou chaves de configuração de nível superior não relacionadas. É idempotente: executá-lo novamente quando o hook já está registrado imprime uma confirmação e não faz alterações, em vez de adicionar uma entrada duplicada. Escrito via renomeação atômica de arquivo temporário, e nunca sobrescreve o arquivo às cegas.
A versão manual, editada à mão, ainda é suportada e útil para solução de problemas ou para entender exatamente o que é escrito — é a mesma forma que init --hooks claude-code produz. Adicione-a a PreToolUse em .claude/settings.json (ou .claude/settings.local.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{ "type": "command", "command": "node /absolute/path/to/node_modules/@cardor/heimdall-mcp/bin/hooks/claude-pretooluse.js" }
]
}
]
}
}
Ainda não implementado:
- Um hook companheiro
PostToolUsepara liberar o bloqueio assim que a chamada de ferramenta for concluída. Os bloqueios adquiridos por este hook são liberados apenas via expiração de TTL (padrão 30s, ou ottlconfigurado da regra), não imediatamente após a ferramenta terminar — uma limitação conhecida, não um bug.
Plugin tool.execute.before do OpenCode
src/plugins/opencode-heimdall.ts (compilado para dist/plugins/opencode-heimdall.js, reexportado por bin/plugins/opencode-heimdall.js) é um plugin do OpenCode que implementa o hook tool.execute.before, seguindo a mesma lógica de carregamento de configuração, correspondência de host e aquisição de bloqueio do hook do Claude Code acima — mas correspondido contra hosts['opencode'] em vez de hosts['claude-code']. Ainda não há política padrão integrada para o OpenCode (sem equivalente OPENCODE_DEFAULT_HOST_POLICY a CLAUDE_CODE_DEFAULT_HOST_POLICY), então este plugin permite toda chamada de ferramenta até que você configure explicitamente hosts.opencode.locks você mesmo, ex.:
// heimdall.config.ts
export default {
hosts: {
opencode: {
locks: {
write: { resource: 'filePath', ttl: 30_000 },
},
},
},
} satisfies HeimdallConfig;
Para a investigação completa do contrato tool.execute.before, método de verificação do mecanismo de negação e questões abertas não resolvidas por trás desta implementação, veja SPIKE_OPENCODE_HOOKS.md.
Ao contrário do hook do Claude Code (um processo separado gerado por chamada de ferramenta, comunicando-se via stdin/stdout), os plugins do OpenCode são resolvidos e executados no processo — o próprio OpenCode import() o módulo do plugin e chama sua função de fábrica exportada uma vez na inicialização; não há subprocesso, sem stdin/stdout, e os hooks resultantes permanecem registrados durante toda a sessão.
Recomendado: execute heimdall-mcp init --hooks opencode para registrar este plugin automaticamente:
heimdall-mcp init --hooks opencode
Isso lê ~/.config/opencode/opencode.jsonc (criando-o se não existir), resolve o caminho absoluto para o bin/plugins/opencode-heimdall.js do pacote instalado e o anexa ao array plugin de nível superior — sem tocar em outras chaves. É idempotente: executá-lo novamente quando o plugin já está registrado imprime uma confirmação e não faz alterações, em vez de adicionar uma entrada duplicada. Escrito via renomeação atômica de arquivo temporário, e nunca sobrescreve o arquivo às cegas.
opencode.jsonc é JSONC genuíno — configurações reais podem e contêm comentários // e vírgulas finais. Este instalador usa jsonc-parser (a mesma biblioteca que o VS Code usa internamente para editar settings.json) para calcular uma edição de texto cirúrgica que anexa a nova entrada do array, em vez de um round-trip simples de JSON.parse/JSON.stringify — para que comentários existentes, vírgulas finais e formatação em outros lugares do arquivo sejam preservados. (Comentários anexados diretamente à linha da própria entrada do array anexada podem mudar em uma entrada como efeito colateral da edição de inserção do array; comentários em outros lugares do arquivo não são tocados.)
A versão manual, editada à mão, ainda é suportada e útil para solução de problemas ou para entender exatamente o que é escrito — é a mesma forma que init --hooks opencode produz. Adicione o caminho do plugin do pacote compilado ao array plugin do seu opencode.jsonc:
// opencode.jsonc
{
"plugin": ["/absolute/path/to/node_modules/@cardor/heimdall-mcp/bin/plugins/opencode-heimdall.js"]
}
Ressalva de confiança sobre o próprio mecanismo de registro. O suporte do array plugin para entradas de caminho de arquivo absoluto simples (sem package.json, sem empacotamento npm) foi verificado com ALTA confiança contra o código-fonte real obtido do OpenCode (sst/opencode, packages/opencode/src/plugin/shared.ts's isPathPluginSpec()/resolvePathPluginTarget()) cruzado com as strings embutidas do binário do OpenCode realmente instalado — veja SPIKE_OPENCODE_HOOKS.md para o método de verificação. Dito isso, como o mecanismo de negação do plugin documentado abaixo, isso não foi validado de ponta a ponta contra um processo OpenCode ao vivo realmente carregando e usando o plugin — trate a etapa de registro, assim como o próprio plugin, como experimental até ser verificado independentemente contra uma sessão OpenCode real.
Ressalva de confiança — leia antes de confiar nisso para segurança. A documentação publicada do próprio OpenCode para tool.execute.before não estava disponível localmente durante o desenvolvimento (veja SPIKE_OPENCODE_HOOKS.md), e seu tipo output é apenas { args: any } — não há campo deny/block/status confirmado neste hook (em contraste com o próprio hook permission.ask do OpenCode, que tem um campo status: "ask" | "deny" | "allow" tipado). Este plugin nega um bloqueio conflitante lançando um Error do callback do hook, na suposição de que uma promise rejeitada aborta a chamada de ferramenta — o padrão convencional para before-hooks que retornam void, e nada encontrado contradiz isso, mas isso não foi confirmado contra uma sessão OpenCode ao vivo. Se essa suposição estiver errada, este plugin falhará silenciosamente em bloquear qualquer coisa enquanto ainda parece negar (ele lança; se o runtime do OpenCode realmente bloqueia a chamada de ferramenta nesse lançamento é não verificado). Trate esta integração como experimental até ser verificada independentemente.
Ainda não implementado:
- Um companheiro
tool.execute.afterpara liberar o bloqueio assim que a chamada de ferramenta for concluída — mesma limitação de liberação apenas por TTL do hook do Claude Code acima. - Confirmação ao vivo do mecanismo de negação descrito acima, e do mecanismo de registro
init --hooks opencoderealmente sendo carregado por um processo OpenCode ao vivo.
Limitações
Uma lista única e canônica de tudo que ainda não foi implementado, ou ainda não verificado independentemente, em bloqueios de recurso e políticas de host. Cada item também tem uma nota inline perto da seção relevante acima com mais contexto local.
- Sem bloqueio em modo de leitura. Todo bloqueio é adquirido em modo exclusivo
'write';LockRuleSchemanão tem campomodeainda, mesmo que o tipoLockStoredefina'read' | 'write'. Veja Semântica acima. - Sem suporte a bloqueio para
Bash.Bashé deliberadamente excluído deCLAUDE_CODE_DEFAULT_HOST_POLICYporque comandos shell arbitrários não têm um único argumento "recurso" estável para bloquear — bloqueá-lo seria ou sem sentido ou perigosamente grosseiro. - Sem liberação automática na conclusão da ferramenta (Claude Code). O hook
PreToolUsenão tem companheiroPostToolUsepara liberar o bloqueio imediatamente quando a chamada de ferramenta termina. Os bloqueios que ele adquire são liberados apenas via expiração de TTL (padrão 30s, ou ottlconfigurado da regra) — não um bug, mas uma limitação conhecida. - Sem liberação automática na conclusão da ferramenta (OpenCode). Mesma limitação do Claude Code acima — o plugin
tool.execute.beforenão tem companheirotool.execute.after; a liberação é apenas por TTL. - Mecanismo de negação do OpenCode não verificado de ponta a ponta. O plugin nega um bloqueio conflitante lançando um
Errordo callbacktool.execute.before, na suposição de que um erro lançado aborta a chamada de ferramenta. Isso não foi confirmado contra uma sessão OpenCode ao vivo. VejaSPIKE_OPENCODE_HOOKS.md. - Registro
init --hooks opencodedo OpenCode não verificado de ponta a ponta. O mecanismo de registro (anexar um caminho de arquivo simples ao arrayplugindoopencode.jsonc) foi verificado com alta confiança contra o código-fonte e o binário instalado do OpenCode, mas não foi validado contra um processo OpenCode ao vivo realmente carregando e usando o plugin. VejaSPIKE_OPENCODE_HOOKS.md.
Para a investigação completa do contrato de hook por trás das ressalvas do Claude Code acima, veja SPIKE_CLAUDE_HOOKS.md. Para a investigação de registro/mecanismo de negação do plugin OpenCode, veja SPIKE_OPENCODE_HOOKS.md.
O que acontece quando uma chamada é bloqueada
A chamada bloqueada nunca chega ao servidor real:
[TelemetryInterceptor] → [PolicyInterceptor] → [ForwardInterceptor]
↑
blocks here, returns JSON-RPC error
O cliente recebe:
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32001,
"message": "Tool 'write_file' is not permitted by policy"
}
}
O span OTel ainda é registrado — com policy.blocked = true e mcp.error.code = -32001 — para que você obtenha uma trilha de auditoria completa do que foi tentado e bloqueado.
tools/list, prompts/list e resources/list também são filtradas: entradas negadas são removidas antes que o cliente as veja. O agente nunca fica sabendo que uma ferramenta negada existe.
Conflitos de bloqueio
Uma tools/call bloqueada por um resource lock ativo retorna um erro JSON-RPC distinto com informações estruturadas do detentor em error.data:
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32600,
"message": "Resource '/repo/file.txt' is locked (requested by tool 'write_file')",
"data": {
"resource_key": "/repo/file.txt",
"held_by": "a1b2c3d4...",
"expires_at": 1735689600000
}
}
}
-32600 (exportado como RESOURCE_LOCKED) é usado deliberadamente aqui, mesmo colidindo com o intervalo reservado "Invalid Request" do JSON-RPC 2.0 — veja o comentário no código em LockInterceptor.ts para a justificativa.
Defina onConflict: 'warn' em uma regra de bloqueio para encaminhar a chamada em vez de bloqueá-la — o span OTel recebe os atributos lock.conflict_warning = true, lock.resource_key, lock.held_by e lock.expires_at em vez de uma resposta de erro.
Nome do servidor
As entradas de política são indexadas pelo nome do servidor. Use --server-name para defini-lo explicitamente na sua configuração MCP:
{
"mcpServers": {
"filesystem": {
"command": "heimdall-mcp",
"args": [
"start",
"--store", "sqlite://~/.heimdall/traces.db",
"--server-name", "filesystem",
"--",
"npx", "@modelcontextprotocol/server-filesystem", "/home/user/projects"
]
}
}
}
--server-name substitui o nome da resposta initialize do servidor — tanto para a consulta de política quanto para o atributo OTel mcp.server.name.
Configuração de política no modo biblioteca
import { ProxyBuilder } from '@cardor/heimdall-mcp';
import type { HeimdallConfig } from '@cardor/heimdall-mcp';
const policy: HeimdallConfig = {
servers: {
filesystem: {
tools: { deny: ['write_file', 'delete_file'] },
},
},
};
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'npx', args: ['@modelcontextprotocol/server-filesystem', '/tmp'] })
.store('sqlite://./traces.db')
.config(policy) // attach policy
.serverName('filesystem') // match config key
.build();
await proxy.start();
Modos de uso
Modo 1 — CLI encapsulando um subprocesso (stdio)
O cliente MCP acha que está falando com heimdall-mcp. O proxy inicia o servidor real como um processo filho e encaminha todas as mensagens.
Configuração mcp.json / Claude Desktop:
{
"mcpServers": {
"my-server": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--", "node", "my-server.js"
]
}
}
}
O separador -- divide as flags do heimdall-mcp do comando do servidor real. Tudo depois dele é executado como um subprocesso.
Com um servidor instalado globalmente:
{
"mcpServers": {
"filesystem": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--", "npx", "@modelcontextprotocol/server-filesystem", "/tmp"
]
}
}
}
Com Postgres em vez de SQLite:
{
"mcpServers": {
"my-server": {
"command": "heimdall-mcp",
"args": [
"--store", "postgres://user:pass@localhost:5432/traces",
"--", "node", "my-server.js"
]
}
}
}
Modo 2 — CLI encapsulando um servidor HTTP remoto
Quando o servidor MCP já está em execução e expõe um endpoint HTTP.
{
"mcpServers": {
"remote-server": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--out", "http",
"--target", "http://localhost:3001"
]
}
}
}
O proxy expõe stdio para o cliente e encaminha cada mensagem como uma POST HTTP para a URL de destino.
Modo 3 — CLI encapsulando um servidor SSE remoto
Para servidores que usam Server-Sent Events.
{
"mcpServers": {
"sse-server": {
"command": "heimdall-mcp",
"args": [
"--store", "postgres://user:pass@host/db",
"--out", "sse",
"--target", "http://remote.example.com"
]
}
}
}
O proxy se conecta a {target}/sse para receber respostas e envia requisições como POST para {target}.
Modo 4 — Biblioteca para desenvolvedores
Quando você tem acesso ao código-fonte e quer integrar o proxy programaticamente.
Configuração mínima:
import { ProxyBuilder } from '@cardor/heimdall-mcp'
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'node', args: ['my-server.js'] })
.store('sqlite://./traces.db')
.build()
await proxy.start()
// clean shutdown
process.on('SIGINT', () => proxy.stop())
stdio → HTTP remoto:
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'http', url: 'http://localhost:3001' })
.store('postgres://user:pass@localhost/traces')
.build()
await proxy.start()
HTTP de entrada (proxy escuta em uma porta):
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'http', port: 8080 })
.outbound({ transport: 'stdio', command: 'node', args: ['server.js'] })
.store('mysql://user:pass@localhost/traces')
.build()
await proxy.start()
Com exportação OTLP e logging de depuração:
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'node', args: ['my-server.js'] })
.store('sqlite://./traces.db')
.otlp('http://localhost:4318/v1/traces') // export to Jaeger / Tempo / Grafana
.setDebug(true) // verbose span logs to stderr
.build()
await proxy.start()
Com um interceptor personalizado:
import type { Interceptor, InterceptorContext, JsonRpcMessage } from '@cardor/heimdall-mcp'
class LogAllInterceptor implements Interceptor {
name = 'LogAllInterceptor'
async intercept(
request: JsonRpcMessage,
context: InterceptorContext,
next: () => Promise<JsonRpcMessage>
): Promise<JsonRpcMessage> {
console.log('→', request.method, request.id)
const response = await next()
console.log('←', response.id, response.error ? 'ERROR' : 'OK')
return response
}
}
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'node', args: ['server.js'] })
.store('sqlite://./traces.db')
.build()
proxy.addInterceptor(new LogAllInterceptor())
await proxy.start()
Stores
SQLite
Nenhum servidor externo necessário — ideal para desenvolvimento local.
Strings de conexão válidas:
sqlite://./traces.db
sqlite://~/.mcp-traces/traces.db
sqlite:///absolute/path/traces.db
Driver: @libsql/client — WASM puro, sem necessidade de compilação nativa.
Schema:
heimdall_spans
span_id TEXT PRIMARY KEY
trace_id TEXT NOT NULL
name TEXT NOT NULL → "mcp.tool.call", "mcp.initialize", etc.
kind INTEGER → OTel SpanKind: 0=INTERNAL, 1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER
status INTEGER NOT NULL → 0=UNSET, 1=OK, 2=ERROR
status_message TEXT
start_time_unix_nano INTEGER NOT NULL → Unix nanoseconds (OTel native)
end_time_unix_nano INTEGER NOT NULL
attributes TEXT/JSON → mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.tool.name, mcp.transport, mcp.status, mcp.error.type, mcp.server.name, mcp.latency.*, duration.ms, etc.
events TEXT/JSON → OTel events array (e.g. error events)
links TEXT/JSON → OTel links array
resource_attributes TEXT/JSON → service.name, service.version, service.namespace (OTel semantic conventions)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
heimdall_metrics
id INTEGER PRIMARY KEY AUTOINCREMENT
tool_name TEXT NOT NULL
call_count INTEGER DEFAULT 0
error_count INTEGER DEFAULT 0
avg_duration INTEGER
updated_at TEXT NOT NULL
Nota: SQLite usa
INTEGERpara timestamps em nanossegundos porque SQLite não tem tipo nativoBIGINT— a afinidade de inteiros lida corretamente com valores grandes.
PostgreSQL
postgres://user:pass@localhost:5432/my_db
postgresql://user:pass@localhost:5432/my_db
Driver: postgres — JS puro, sem node-gyp.
Diferenças de schema em relação ao SQLite:
start_time_unix_nano/end_time_unix_nano→BIGINT(64 bits nativo, exato para nanossegundos)attributes/events/links/resource_attributes→JSONB(indexável, consultável)avg_duration→REALupdated_at→TIMESTAMPcreated_at→TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
MySQL
mysql://user:pass@localhost:3306/my_db
Driver: mysql2.
Diferenças de schema em relação ao SQLite:
span_id/trace_id/name→VARCHAR(64/512)(comprimentos explícitos)start_time_unix_nano/end_time_unix_nano→BIGINT(64 bits nativo, exato para nanossegundos)attributes/events/links/resource_attributes→JSONavg_duration→FLOATidem métricas →BIGINT UNSIGNED AUTO_INCREMENTupdated_at→TIMESTAMP(3)(precisão de milissegundos)
O que é registrado
Cada mensagem JSON-RPC produz um span na tabela heimdall_spans. Todos os atributos seguem o namespace mcp.* para interoperabilidade com outras ferramentas compatíveis com MCP.
| Método MCP | Nome do span | Atributos principais |
|---|---|---|
initialize | mcp.initialize | mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.transport, mcp.status, duration.ms |
tools/list | mcp.tools.list | + mcp.server.name, mcp.server.version, mcp.response_mode, mcp.response.body_hash |
tools/call | mcp.tool.call | + mcp.tool.name, mcp.request.body_hash, mcp.response.body_hash, mcp.latency.proxy_to_server_ms, mcp.latency.proxy_overhead_ms |
resources/read | mcp.resource.read | + mcp.server.name, mcp.server.version |
resources/list | mcp.resources.list | + mcp.server.name, mcp.server.version |
prompts/get | mcp.prompt.get | + mcp.server.name, mcp.server.version |
prompts/list | mcp.prompts.list | + mcp.server.name, mcp.server.version |
shutdown | mcp.shutdown | mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.transport, mcp.status, duration.ms |
| qualquer outro | mcp.{method} | mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.transport, mcp.status, duration.ms |
Atributos comuns em todo span:
| Atributo | Descrição |
|---|---|
mcp.rpc.system | Sempre "mcp" |
mcp.jsonrpc.method | O nome do método JSON-RPC |
mcp.jsonrpc.id | id JSON-RPC do frame, convertido para string. Vazio para notificações |
mcp.trace.request_id | ID de correlação por requisição gerado pelo proxy (estável entre IDs JSON-RPC numéricos/string/null) |
mcp.transport | stdio, http ou sse |
mcp.status | ok · error · timeout · cancelled |
mcp.server.name | Nome do servidor MCP real (capturado da resposta initialize) |
mcp.server.version | Versão do servidor MCP real (capturada da resposta initialize) |
duration.ms | Latência total de ida e volta em milissegundos |
Detalhamento de latência (em tools/call):
| Atributo | Descrição |
|---|---|
mcp.latency.proxy_to_server_ms | Tempo que o servidor real levou para responder |
mcp.latency.proxy_overhead_ms | Overhead introduzido pelo próprio proxy |
Modos de captura de corpo:
A captura de corpo é controlada por --body-mode (CLI) ou .setBodyMode() (biblioteca). O padrão é redacted.
| Modo | mcp.tool.request / mcp.tool.response | mcp.request.body_hash | mcp.response.body_hash |
|---|---|---|---|
redacted (padrão) | — (omitido) | sha256:<hex> (sempre) | [redacted] |
hash | — (omitido) | sha256:<hex> (sempre) | sha256:<hex> |
full | JSON bruto | sha256:<hex> (sempre) | sha256:<hex> |
mcp.request.body_hash é sempre um hash real, independentemente do modo — use-o para correlacionar chamadas idênticas repetidas sem expor o payload. mcp.response.body_hash segue a configuração de redação.
Use
fullapenas para desenvolvimento local — corpos brutos em backends OTLP compartilhados podem vazar segredos.
Correlação de agente (opcional):
Se o cliente MCP enviar um objeto _meta dentro de params, o heimdall-mcp extrairá e registrará automaticamente estes atributos:
Campo _meta | Atributo de span |
|---|---|
conversationId | gen_ai.conversation.id |
turnId | gen_ai.turn.id |
agentRunId | gen_ai.agent.run.id |
Como fallback, as variáveis de ambiente MCP_CONVERSATION_ID, MCP_TURN_ID e MCP_AGENT_RUN_ID são usadas se definidas.
Em caso de erro, todo span também recebe mcp.error.type (protocol · tool · proxy · transport), mcp.error.message e mcp.error.code além de um evento OTel error anexado ao span.
Quando uma chamada é bloqueada por política, o span também inclui policy.blocked = true — para que você possa consultar chamadas tentadas-mas-bloqueadas separadamente de erros reais.
A coluna resource_attributes de todo span contém metadados de recurso OTel:
service.name—@cardor/heimdall-mcpservice.version— versão do pacoteservice.namespace—mcp-proxy
O schema segue o modelo de dados OpenTelemetry nativamente:
- Timestamps armazenados como nanossegundos Unix (
BIGINTem Postgres/MySQL,INTEGERem SQLite) kindé um SpanKind inteiro (0=INTERNAL, 1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER)statusé um SpanStatusCode inteiro (0=UNSET, 1=OK, 2=ERROR)- Colunas JSON mapeiam diretamente para sacos de atributos OTLP
Isso significa que as linhas podem ser consumidas diretamente por qualquer ferramenta compatível com OTel, sem transformação.
Jaeger UI (OTLP)
O heimdall-mcp pode exportar cada span para uma instância Jaeger em tempo real via OTLP HTTP, para que você visualize traces sem consultar o banco de dados diretamente.

1. Inicie o Jaeger
docker run -d \
--name jaeger \
-p 16686:16686 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
2. Adicione --otlp à sua configuração
{
"mcpServers": {
"my-server": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--otlp", "http://localhost:4318/v1/traces",
"--", "node", "my-server.js"
]
}
}
}
Para a variante HTTP/SSE (ex.: a configuração usada durante o desenvolvimento deste projeto):
{
"mcpServers": {
"my-server": {
"command": "sh", "-c",
"args": [
"heimdall-mcp --store postgresql://user:pass@localhost:5432/db --out http --target http://localhost:3000/mcp --otlp http://localhost:4318/v1/traces"
]
}
}
}
3. Abra a UI do Jaeger
http://localhost:16686
Selecione o serviço heimdall-mcp e clique em Find Traces. Cada método MCP (mcp.tool.call, mcp.initialize, mcp.tools.list, …) aparece como um trace separado com atributos completos e corpos de eventos de entrada/saída.
Modo escuro — acrescente ?uiConfig={"theme":"dark"} à URL, ou monte um arquivo de configuração:
echo '{"uiConfig":{"theme":"dark"}}' > jaeger-ui.json
docker rm -f jaeger && docker run -d \
--name jaeger \
-p 16686:16686 \
-p 4318:4318 \
-v $(pwd)/jaeger-ui.json:/etc/jaeger/ui-config.json \
-e JAEGER_UI_CONFIG_FILE=/etc/jaeger/ui-config.json \
jaegertracing/all-in-one:latest
A flag
--otlpé aditiva — os spans são salvos no banco de dados e exportados para o Jaeger ao mesmo tempo.
Interceptors personalizados
A interface Interceptor é pública. Você pode adicionar sua própria lógica ao pipeline antes do interceptor de telemetria:
interface Interceptor {
name: string
intercept(
request: JsonRpcMessage,
context: InterceptorContext,
next: () => Promise<JsonRpcMessage>
): Promise<JsonRpcMessage>
}
interface InterceptorContext {
startedAt: Date
traceId: string
spanId: string
bodyMode: 'redacted' | 'hash' | 'full'
transport: 'stdio' | 'http' | 'sse'
serverInfo: { name?: string; version?: string } // populated after initialize
conversationId?: string // from _meta or env var
turnId?: string
agentRunId?: string
metadata: Record<string, unknown> // shared bag between interceptors
}
Chamar next() passa o controle para o próximo interceptor na cadeia. ForwardInterceptor é sempre o último — ele faz a chamada real ao servidor e registra latency.proxy_to_server_ms em context.metadata para o interceptor de telemetria ler.
Você pode usar context.metadata para passar dados entre seu interceptor e outros na mesma execução do pipeline.
Referência da CLI
start
O comando padrão. Inicia o proxy.
heimdall-mcp start [options] [-- command [args...]]
heimdall-mcp [options] [-- command [args...]] # "start" is optional
Options:
--store <url> Store connection string (required)
sqlite://./traces.db
postgres://user:pass@host/db
mysql://user:pass@host/db
--lock-store <url> Lock store connection string (optional)
sqlite:// | postgres:// | mysql://
defaults to ~/.config/heimdall/locks.db
--out <transport> Transport to the real server (default: stdio)
stdio | http | sse
--target <url> Server URL when --out is http or sse
--in <transport> Inbound transport (default: stdio)
stdio | http | sse
--in-port <port> Port for --in http or --in sse
--otlp <url> Export spans to an OTLP HTTP endpoint (e.g. Jaeger, Tempo)
Additive — spans are also saved to the store
Example: http://localhost:4318/v1/traces
--body-mode <mode> Body capture mode for tool args and responses (default: redacted)
redacted → stores [redacted] + size (safe for production)
hash → stores sha256:<hex> + size (safe for production)
full → stores raw JSON (local/dev only — may leak secrets)
--server-name <name> Override server name for policy lookup and mcp.server.name OTel attribute.
If not provided, falls back to serverInfo.name from the initialize response.
--out-port <port> Port for outbound http or sse transport
--debug Write verbose logs to stderr (prints span names + trace IDs to stderr)
-V, --version Print version
-h, --help Print this help
-- Separates proxy flags from the subprocess command
(required when --out is stdio)
Examples:
# stdio proxy → subprocess
heimdall-mcp --store sqlite://./t.db -- node server.js
# stdio proxy with policy enforcement
heimdall-mcp --store sqlite://./t.db --server-name filesystem -- npx @modelcontextprotocol/server-filesystem /tmp
# stdio proxy → remote HTTP server
heimdall-mcp --store sqlite://./t.db --out http --target http://localhost:3001
# stdio proxy → remote SSE server with Postgres
heimdall-mcp --store postgres://user:pass@host/db --out sse --target http://remote.com
# with OTLP export to Jaeger
heimdall-mcp --store sqlite://./t.db --otlp http://localhost:4318/v1/traces -- node server.js
start descobre automaticamente heimdall.config.{ts,js,json} no diretório de trabalho atual e ~/.config/heimdall/heimdall.config.* na inicialização. Erros de carregamento de configuração imprimem um aviso, mas nunca derrubam o proxy.
init
Gera um heimdall.config.ts com exemplos comentados. Com --hooks <host>, registra um hook de ferramenta nativa para um agente de codificação (veja Claude Code PreToolUse hook e plugin OpenCode tool.execute.before).
heimdall-mcp init [options]
Options:
--global Write to ~/.config/heimdall/ instead of the current directory
--format <fmt> File format: ts | js | json (default: ts)
--force Overwrite an existing config file
--hooks <host> Register a native-tool PreToolUse hook for a coding agent instead of
writing a config file (supported: claude-code, opencode)
# Create local config in current directory
heimdall-mcp init
# Create global config (applies to all projects)
heimdall-mcp init --global
# Create as JSON
heimdall-mcp init --format json
# Register the Claude Code PreToolUse lock-enforcement hook in ~/.claude/settings.json
heimdall-mcp init --hooks claude-code
# Register the OpenCode plugin in ~/.config/opencode/opencode.jsonc
heimdall-mcp init --hooks opencode
health
Valida a configuração de política mesclada e relata políticas por servidor. Não se conecta a nenhum servidor MCP.
heimdall-mcp health [options]
Options:
--config <path> Path to a specific config file (skips auto-discovery)
Exemplo de saída:
Config files loaded:
global: ~/.config/heimdall/heimdall.config.ts
local: ./heimdall.config.ts
Default policy:
tools: allow=[*] deny=[none]
Server policies:
filesystem:
tools: allow=[read_file, list_directory, search_files] deny=[write_file, create_file, delete_file, move_file]
database:
tools: allow=[query, describe_table, list_tables] deny=[execute, drop_table, truncate]
No conflicts detected.
Se a mesma entidade aparecer tanto em allow quanto em deny, health sai com código 1 e lista todos os conflitos.
Referência de tracing
O vocabulário completo de atributos — atributos obrigatórios, atributos opcionais, campos de captura de corpo, classificação de erros e exemplos de spans anotados para initialize, tools/list, tools/call e um caso de falha de proxy — está documentado em TRACING.md.
Roadmap
| Fase | Recurso | Status |
|---|---|---|
| 1 | heimdall.config.{ts,js,json} descoberto automaticamente — local + global, listas de permitir/negar por servidor | ✅ Concluído (v1.2) |
| 2 | PolicyInterceptor — chamadas bloqueadas retornam erro JSON-RPC -32001, registrado no OTel com policy.blocked = true | ✅ Concluído (v1.2) |
| 3 | Comandos CLI heimdall-mcp init + heimdall-mcp health | ✅ Concluído (v1.2) |
| 4 | Vocabulário de rastreamento estável — mcp.jsonrpc.id, mcp.trace.request_id, mcp.error.type, hashes de corpo por direção, especificação TRACING.md | ✅ Concluído (v1.3) |
| 5 | Filtrar por tipo de ação (read / write / execute) inferido do nome da ferramenta ou metadados do MCP | 📋 Planejado |
| 6 | Modos de aplicação em tempo de execução: warn (registrar + encaminhar), audit (span com policy.violation = true) | 📋 Planejado |
| 7 | Bloqueios de recurso — esquema de configuração locks, LockStore, expiração baseada em TTL, código de erro JSON-RPC específico para bloqueio, resolução de caminho canônico, armazenamentos suportados por Postgres/MySQL, integrações de hooks | 🚧 Em andamento (bloqueio de modo de escrita com resolução de caminho canônico, código de erro RESOURCE_LOCKED, modos de rejeitar/avisar onConflict e armazenamentos suportados por Postgres/MySQL implementados; integrações de hooks e bloqueio de modo de leitura pendentes) |