Dev.to AI 🤖 Ai 👁 0 📖 16 min read

MCP - Your data access layer in AIoT projects

When framing IoT projects, large amounts of granular data become the typical scenario. As non-deterministic systems bring a whole new set of automations, we want to place a layer between artificial intelligence (AI) and

When framing IoT projects, large amounts of granular data become the typical scenario. As non-deterministic systems bring a whole new set of automations, we want to place a layer between artificial intelligence (AI) and proprietary data. Within this layer, we can define access policies, monitoring, and the determinism the operation requires to trust the developed solution. At smapiot, we have been using the Model Context Protocol (MCP) to achieve that shell of reliability in our AIoT projects, and that is what we discuss in the following article.

Setting the Context

At smapiot GmbH, we have a long track record of consulting in IoT projects. The past years have shown how AI technology has been capable of streamlining industrial manufacturing operations. Therefore, we only expect an increase in demand for adding AI features to IoT projects. That has been summarized by a new field called Artificial Intelligence of Things, or AIoT.

The artificial intelligence of things (AIoT) is the combination of AI technologies and the internet of things IoT infrastructure. AIoT's goal is to create more efficient IoT operations, improve human-machine interactions, and enhance data management and analytics.
https://www.techtarget.com/iotagenda/definition/Artificial-Intelligence-of-Things-AIoT

AIoT also stands as an application of AI Engineering. Both fields, even though modern, have already gone through some significant evolution. When we first started integrating Large Language Models (LLMs) into software, shaping the system prompt was our main way to control the model's behavior in a programmatic request. However, prompts alone cannot fetch data to ground the model or execute repeatable operations. For those tasks, we want to write a standard script and expose it to the AI so it knows when to use it.

LLM grounding is the process of linking a large language model's outputs to trusted, external sources of truth. Instead of relying solely on what the LLM learned during pre-training, grounding allows it to retrieve and use up-to-date, factual, or organization-specific information when generating responses.
https://nexos.ai/blog/what-is-llm-grounding/

Let's see how to expand AI capabilities beyond the model.

What is MCP?

The Model Context Protocol is an open standard designed to decouple AI applications from data sources and actionable tools. Instead of writing custom bindings for every AI agent and every single database or IoT protocol, MCP provides a protocol-level bridge. Think of it as a uniform interface where any MCP client can discover and consume data resources, prompt templates, and executable tools provided by an MCP server without needing prior knowledge of the underlying infrastructure. We will dive deeper into MCP primitives later in this text.

In the real world, data rarely lies in a single neat place. In IoT scenarios, it is typically scattered across time-series engines, asset management systems, and relational databases. MCP gives us a clean architectural boundary so an MCP client does not have to worry about connection strings, drivers, or backend protocols: it simply queries the MCP server using standardized messages.

It is important to note that a full cycle of interaction between an AI-powered service and an MCP server involves other protocol concepts, such as Streamable HTTP transports and Server-Sent Events (SSE), that we are unable to pack into a single article. Because MCP is an open standard, its official documentation is the best resource to fully understand capability negotiations, lifecycle management, and secure protocol configuration.

The Anatomy of an MCP Tool

When designing an MCP tool, reliable model interaction depends on how clearly the tool and its underlying function are structured. Because the LLM relies on a machine-readable schema rather than manual documentation, every field exposed by the server matters:

{
  "name": "query_asset_database",
  "description": "Query metadata and operational thresholds for factory machinery from the enterprise PostgreSQL database",
  "inputSchema": {
    "type": "object",
    "properties": {
      "asset_id": {
        "type": "string",
        "description": "The unique identifier of the factory asset, e.g., 'TURBINE-04'"
      }
    },
    "required": ["asset_id"]
  }
}

When looking under the hood of the Model Context Protocol, this JSON structure is part of the standard wire format exchanged between an MCP client and server via JSON-RPC 2.0. While an AI agent relies on this tool definition to understand a tool's exact capabilities, input types, required fields, and optional output schemas, we as developers rarely have to write raw JSON by hand.

MCP uses JSON-RPC 2.0 as its wire format. The transport layer is responsible for converting MCP protocol messages into JSON-RPC format for transmission and converting received JSON-RPC messages back into MCP protocol messages.
https://modelcontextprotocol.info/docs/concepts/transports/

Moving forward in our examples, we will use Python and the typical conventions used by the FastMCP framework, which automatically inspects native function signatures, type hints, and docstrings to compile these complex schemas for us on the fly.

from fastmcp import FastMCP

# Initialize the FastMCP server
mcp = FastMCP("Enterprise IoT DAL")

@mcp.tool()
def query_asset_database(asset_id: str) -> str:
    """
    Query metadata and operational thresholds for factory machinery
    from the enterprise PostgreSQL database.

    Args:
        asset_id: The unique identifier of the factory asset, e.g., 'TURBINE-04'
    """
    # Your deterministic database logic goes here
    return f"Fetching records for asset: {asset_id}"

Let's look in detail at each part of an MCP tool.

Function Name

It all starts with the function (tool) signature, specifically the function name, in our case: query_asset_database. In traditional programming, a function name is just an internal identifier for developers to call within codebases. But when talking about an MCP tool, the function name becomes a semantic label exposed to the LLM.

def query_asset_database

The model reads this identifier once discovered at runtime (via tools/list) to build its internal "mental map" of what capabilities are available. Following a clean action-noun convention (like query_asset_database, get_telemetry, or restart_gateway) helps the LLM immediately recognize intent and map natural language user prompts to the correct programmatic block. Under the hood using FastMCP, this exact function name is automatically translated into the name field of the tool definition in the wire format, serving as the direct routing key when the model decides to trigger an execution. How we name our tools matters.

Function Arguments

When shaping an MCP tool, defining the input arguments is where we explicitly tell the LLM what contextual inputs are required to get the job done. Looking at our tool definition, asset_id acts as the primary parameter. In an IoT scenario, the AI cannot guess which machine the user is asking about: a specific target variable must be supplied. By designing clean, well-defined arguments, we give the LLM the exact handles it needs to extract the right information from the user's natural language request and map it directly into the programmatic function:

def query_asset_database(asset_id: str) -> str:

However, seeing the example above, it quickly becomes clear that "only" naming function arguments properly might not be sufficient. The AI needs to know not only which arguments a tool needs, but also what data type each argument should be, and that is how we come to the importance of static types.

Static Types

With MCP, static type annotations serve a dual purpose. First, they act as an explicit contract for the MCP client, telling the model precisely what data format (string, integer, boolean) is expected. Second, they allow frameworks like FastMCP to validate incoming payloads automatically before your core database logic ever executes. This guarantees that any malformed input generated by a non-deterministic model is caught right at the boundary.

Docstring Description

In traditional software engineering, docstrings are written primarily for human developers reading IDE tooltips or generated API documentation. But in an MCP architecture, the docstring becomes a tool user manual for the LLM during tool calling.

Tool calling is the ability of an AI model to interact with external tools, APIs, or systems to perform actions and retrieve information beyond what the model can do on its own. Rather than relying solely on the knowledge baked into its training data, a model with tool-calling capabilities can recognize when a user's request requires outside help, select the appropriate tool, and structure a request to get the job done.
https://www.databricks.com/blog/what-is-tool-calling

    """
    Query metadata and operational thresholds for factory machinery
    from the enterprise PostgreSQL database.

    Args:
        asset_id: The unique identifier of the factory asset, e.g., 'TURBINE-04'
    """

When an AI connects to your server, the model reads this description to understand not just what arguments a function takes, but when and why it should use this tool. A vague description may cause the LLM to misinterpret its purpose, leading to failed reasoning loops.

Are docstrings and types removed during execution?
Not in our Python MCP setup. Unlike compiled languages that strip away comments and type hints at build time, Python preserves docstrings and type annotations as runtime attributes. Frameworks like FastMCP inspect them during server startup, transforming them on the fly into the JSON schema required for the LLM to understand and trigger tool calls.

Crossing the Enterprise Bridge

Crossing from the base theory to the enterprise environment means adding some shells of complexity. Luckily for our subject of the day, there is only one key additional concept to have our example tool shaped for production:

Returning Structured Results and Status

When building production-grade MCP tools, returning a simple raw string or a bare dictionary is usually not enough. As a best practice, a tool should return a formalized response structure. Through the FastMCP framework, that is how it looks:

from fastmcp.tools import ToolResult
from fastmcp.exceptions import ToolError

@mcp.tool()
def query_asset_database(asset_id: str) -> ToolResult:
    """
    Query metadata and operational thresholds for factory machinery
    from the enterprise PostgreSQL database.

    Args:
        asset_id: The unique identifier of the factory asset, e.g., 'TURBINE-04'
    """
    try:
        data = fetch_records_from_db(asset_id)
        return ToolResult(
            meta={"title": "Asset Database Response"},
            content=data
        )
    except Exception as e:
        raise ToolError(f"Database query failed: {e}")

Because the Model Context Protocol is language-agnostic, at the payload level (the wire format mentioned above), that ToolResult interface would be seen as:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{'asset_name': 'Turbine 04', 'status': 'operational', 'threshold_temp_c': 85.0}"
      }
    ],
    "isError": false,
    "_meta": {
      "title": "Asset Database Response"
    }
  }
}

And if an exception occurs inside your try/except block, the wire format cleanly flags the error to the LLM without crashing the protocol session:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Database query failed: connection timeout to PostgreSQL host"
      }
    ],
    "isError": true
  }
}

The LLM receives explicit feedback nicely wrapped in a standardized JSON response format containing a clear status flag. If a database query fails or an asset ID is missing, the model does not just guess or crash: it reads the "isError": true payload, interprets what went wrong, and can autonomously decide to correct its parameters or inform the user accordingly. Now we have a tool ready for enterprise operations.

MCP as the IoT Data Access Layer

We covered the key concepts around the meaning of an MCP tool, let's use it. At smapiot, our vision for the usage of this new entity to the architecture of IoT projects follows these main mindsets:

  1. As a gateway for enabling proprietary data access
  2. Navigating how AI uses tools in context and across multiple execution paths
  3. Scripting processes while diminishing tasks delegated to AI

Let's discuss these ideas:

1. As a gateway for enabling proprietary data access

The process of accessing a database programmatically looks very similar regardless of the technology: a database server listens for connections and enforces permissions, while a client uses a connection string and credentials to authenticate. Once that access logic is defined, we can execute it imperatively through code.

Furthermore, when dealing with a SQL database, we know we are dealing with a solid (until the next migration) schema. Why not, for example, serve it via an MCP resource?

The Model Context Protocol provides three main server primitives: tools, prompts, and resources. Tools have been discussed in this article as executable functions. Prompts explain themselves: reusable, versioned prompt templates exposed by the server. Finally, resources are read-only data sources (both text and binary) identified by a URI, such as configuration files, device schematics, or the static schema of a SQL database, that we can attach directly as context for the model.

By handling data access this way, the MCP server acts as our single gateway between the AI and our proprietary data. Instead of giving connection credentials directly to an AI agent or letting it attempt arbitrary database connections, the credentials and the imperative scripts stay safely inside our MCP server.

The AI only gets to know what it needs to know. The LLM does not need to know the database password or how connection pooling works. It reads the database schema provided as a resource to understand the domain, and calls a predefined tool to execute the actual query. The gateway takes care of authenticating, executing the programmatic script, and returning only the required data to the model.

Regarding an execute_sql_query tool, because a dedicated Python function wraps the database call itself, we can also enforce the observability we want and the query sanitization we need. For example:

import logging
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.tools import ToolResult

logger = logging.getLogger("mcp_database_gateway")
mcp = FastMCP("Enterprise IoT DAL")

FORBIDDEN_KEYWORDS = {"DELETE", "CREATE", "DROP", "ALTER", "TRUNCATE", "UPDATE", "INSERT"}

@mcp.tool()
def execute_sql_query(query: str) -> ToolResult:
    """
    Execute a read-only SQL query against the industrial metadata database.

    Args:
        query: The raw SQL SELECT query to be executed.
    """
    # 1. Observability: log the incoming query generated by the AI
    logger.info(f"AI agent initiated query execution: {query}")

    # 2. Query sanitization: guardrail against destructive operations
    query_tokens = query.strip().upper().replace(";", " ").split()
    if any(keyword in query_tokens for keyword in FORBIDDEN_KEYWORDS):
        logger.warning(f"Blocked unauthorized query attempt: {query}")
        raise ToolError(
            "Error: Non-read-only queries (e.g., DELETE, CREATE, DROP) are strictly forbidden."
        )

    # 3. Execution: run query safely within our internal scope
    try:
        query_result = run_database_select(query)
        return ToolResult(
            meta={"title": "Query Result"},
            content=query_result
        )
    except Exception as e:
        logger.error(f"Database query failed: {str(e)}")
        raise ToolError(f"Database execution error: {str(e)}")

The flexibility of this architecture enables a wide range of constructs using the Model Context Protocol.

One can argue that a tool like execute_sql_query inevitably adds the non-determinism back into programmatic code, since the LLM writes the SQL string. That argument is correct. However, in our experience at smapiot, we still prefer this over the agent-as-a-tool pattern, which can be summarized as making another LLM call inside an MCP tool. In the example above, query sanitization strictly bounds the risk of the non-deterministic tool call while keeping the MCP server itself purely deterministic. Still, the argument is valid and could be the topic of a new article by itself.

2. Navigating how AI uses tools in context and across multiple execution paths

We were twice surprised by the AI's behavior: first, when sending the complete conversation history in all interactions with the LLM, we quickly noticed an automatic in-context caching occurring with tool responses. Second, the interpretation of multiple tool descriptions can lead to different execution paths leading to the exact same objective. Let us look deeper into these two points:

LLMs are by nature stateless. If we want to maintain a long conversation, we need to remind the model what we have been speaking about. This practice has recently been formalized as context engineering. Context management can be handled with different architectural approaches: from an in-memory database that tracks session state and selectively curates message history (caching), to simply re-sending the whole raw conversation history in every LLM interaction during an MVP phase. For the sake of simplicity, let us focus on the second approach here.

Context engineering is the practice of deliberately designing, structuring and optimizing the context provided to a large language model (LLM) to produce more accurate, relevant and reliable outputs.
https://www.ibm.com/think/topics/context-engineering

While sending the previous user inputs and model responses in every interaction, we also re-send the history of earlier tool calls and their outputs. When the LLM notices that a previously executed tool result is already present in the active conversation context, the model recognizes that running the same tool call again is redundant. It avoids repeating the operation and directly reuses the existing response. In this manner, effective in-context caching is achieved, saving unnecessary round trips to our backend systems and avoiding extra tool-call turns that would further consume the token budget.

Our second surprise occurred after adding multiple MCP tools that could eventually be used to achieve the same result. As a simple example, having an explicit MCP tool like get_latest_registered_user produces the exact same outcome as calling execute_sql_query to select the record with the most recent registration timestamp.

When an AI process chooses different paths to reach the same result, writing automated integration tests becomes tricky. Once it happens, we can easily understand the issue, but finding a stable solution requires careful balancing. If a specific tool-calling sequence must be strictly respected, guiding the model with chain-of-thought examples inside the system prompt is often the strongest path. At the same time, we do not want to overload the system prompt, as it is transmitted on every single interaction and consumes the context budget. In that scenario, refining the docstrings of our tools remains our primary defense to define operational boundaries. Fortunately, the AI architecture remains flexible enough to allow different solutions for reaching the same destination.

A system prompt defines the global behavioral rules, constraints, and operational persona for the LLM. Chain-of-thought (CoT) prompting represents a strategy where we guide the model to break complex decisions into structured, intermediate reasoning steps, showing concrete examples of how to evaluate choices step by step:

User: "Is TURBINE-04 currently running above its temperature threshold?"
Thought:
1. Prefer dedicated tools over `execute_sql_query` whenever a matching tool exists.
2. Step 1: Call `query_asset_database` first to get the configured `threshold_temp_c` for TURBINE-04.
3. Step 2: Call `get_telemetry` to fetch the current live temperature for TURBINE-04.
4. Compare the live reading against `threshold_temp_c` and return the result.
Action 1: query_asset_database(asset_id="TURBINE-04")
Action 2: get_telemetry(asset_id="TURBINE-04")

3. Scripting processes while diminishing tasks delegated to AI

Turning a process into a deterministic script can be understood as a deliberate decision to avoid relying blindly on LLM reasoning for specific tasks. By moving procedural logic into code, we directly reduce the possibility of hallucinations. Through an MCP tool, a task is strictly defined and bounded, which is different from describing multi-step instructions inside a prompt and hoping the AI will execute every step properly.

In IoT and industrial environments, operations often demand strict sequences: reading a sensor threshold, checking safety tolerances, validating against calibration tables, and only then updating a state or logging an incident. If we delegate that entire procedural workflow to an LLM via prompt instructions, we potentially inject operational risk. By wrapping that sequential logic inside a programmatic script exposed as an MCP tool, we achieve two major architectural benefits:

  • Deterministic Execution: The procedural business logic runs in standard Python code where we write unit tests, handle exceptions, and guarantee predictable behavior.

  • Focused AI Responsibility: The LLM does what it does best-understanding the user's natural language intent and extracting parameters, while our code handles the deterministic execution.

In this way, we diminish the surface area of tasks delegated to probabilistic model reasoning. We reserve the intelligent layer for semantic interpretation and decision-making, while keeping data access and critical system operations strictly deterministic.

In an ideal scenario of achieving a closed AI system, one could argue that we come closer to the need for a specialized decision model instead of a general-purpose Large Language Model. This opens a whole new, modern, and very interesting Pandora's box. For now, we have already reached our destination.

Conclusions

Throughout this article, we demonstrated how the Model Context Protocol serves as a practical Data Access Layer for modern AIoT architectures. By encapsulating database interactions and IoT backend logic inside structured MCP tools, we achieve a clean separation of concerns: the LLM handles natural language understanding and semantic intent matching, while our imperative code maintains complete control over connection security, query sanitization, and execution safety.

Serving static database schemas as MCP resources, reusing tool results in context, and replacing fragile prompt instructions with solid Python scripts all point to the same conclusion: MCP gives us the abstraction boundary we need between non-deterministic AI models and the predictability, observability, and trust required by enterprise systems.

As we continue applying this approach across our IoT projects, we will certainly share further experiences through posts similar to this one. We encourage following smapiot content for deeper insights into how we integrate these modern engineering paradigms across our client solutions.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.