Class: Insika::McpToolIngestor

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/mcp_tool_ingestor.rb

Overview

LIVE MCP ingestion (/ spec): discovers the tools of an MCP instance at RUNTIME (no hand-written manifest) and ingests them as data-tools. Given an McpStore instance + an INJECTABLE MCP client (duck-typed: #list_tools -> [{name, description, inputSchema}]), it builds a ToolManifest and REUSES the ingestion path (the:import_tools Command: batch upsert into the ToolStore + hot reload + per-tool report + partial- failure isolation R4). The ToolManifest MCP adapter (inputSchema) is reused — no schema parsing here.

GENERIC: nothing here mentions a consumer/gateway. The MCP instance is DATA in the store.

BINDING STRATEGY (this stage's choice, bounded):

Each discovered tool becomes an HTTP data-tool that makes a JSON-RPC 2.0
`tools/call` POST to the instance endpoint (url). The tool name is resolved
at INGESTION (literal in the body); the model's arguments come in as `{{param}}`
per TOP-level property of the inputSchema (with quoting by type — strings
quoted, others raw, via the DataDefinedTool :body encode). This way the tool
runs through the SAME HTTP path as the other data-tools (egress guard, secret
headers, hot reload) — no new execution code.

Each tool gets `group: "mcp:<instance>"` so the per-group gating
(tools_allow_groups) works for free.

DEFERRED / OUT-OF-SCOPE (documented — spec):

- Real MCP transport: only instances with a `url` (http transport) are ingestible;
stdio has no HTTP endpoint -> raises a clear error (later work).
- MCP session lifecycle (initialize/negotiation/session-id/notifications) and the
UNWRAP of the `tools/call` response (`{content:[{type,text}]}`) — the binding
makes a stateless POST and returns the raw body (extract body_raw). A server
that requires the handshake before answering `tools/list`/`tools/call` is not
reachable yet — that IS the real transport, not this minimal client.
- Tools whose name/top-level property is outside the ToolDefinition NAME_RE
(uppercase/hyphens) are ISOLATED into `errors[]` by the import (R4).

CREDENTIAL INJECTION: the instance env (Hash) is sent verbatim as HTTP headers, on BOTH the discovery request (tools/list, this file) and every ingested tool's tools/call binding (build_manifest's defaults.headers) — literal header-name -> value, the same convention as a data-tool's own secret_headers (the operator types the full value, "Bearer xxx" included; no magic prefixing). An instance with no env sends the bare Content-Type header only, byte-for-byte what shipped before this.

Instance Method Summary collapse

Constructor Details

#initialize(mcp_store:, import_tools:, client_factory: nil) ⇒ McpToolIngestor

Returns a new instance of McpToolIngestor.



48
49
50
51
52
53
54
# File 'lib/insika/mcp_tool_ingestor.rb', line 48

def initialize(mcp_store:, import_tools:, client_factory: nil)
  @mcp_store = mcp_store
  @import_tools = import_tools
  # Per-instance client factory (default: minimal JSON-RPC HTTP client).
  # Injectable for tests (Fake) and to swap for a real transport later.
  @client_factory = client_factory || method(:default_client)
end

Instance Method Details

#ingest(name, client: nil) ⇒ Object

Discovers + ingests the tools of instance name. client is injectable (Fake in tests); absent -> the factory builds one from the record. -> the import_tools report + instance: ({ instance:, version:, created:, updated:, errors: }).



59
60
61
62
63
# File 'lib/insika/mcp_tool_ingestor.rb', line 59

def ingest(name, client: nil)
  manifest = manifest_for(name, client: client)
  report = @import_tools.call(Insika::Command.build(:import_tools, manifest, transport: :internal))
  report.merge(instance: name.to_s)
end

#manifest_for(name, client: nil) ⇒ Object

Discovers the tools and builds the manifest Hash (without ingesting) — isolable for testing.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/insika/mcp_tool_ingestor.rb', line 66

def manifest_for(name, client: nil)
  record = @mcp_store.get_raw(name.to_s)
  raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
  raise Insika::ValidationError, "MCP instance '#{name}' is disabled" unless record["enabled"]

  url = presence(record["url"])
  if url.nil?
    raise Insika::ValidationError,
          "MCP instance '#{name}' has no url: live ingestion requires HTTP transport " \
          "(stdio is later work)"
  end

  tools = Array((client || @client_factory.call(record)).list_tools)
  build_manifest(name.to_s, url, tools, env_headers(record))
end