Traduzione da Amazon Translate
Discovery

Thoughts on the MCP Architecture

In this blog post, we’ll explore the MCP protocol through the lens of its architectural implications — specifically, how abstraction, dependency, and practical adoption intersect and influence each other.

Here’s what we’ll cover. Feel free to click on any section below to jump directly to that part of the discussion.

Table of Contents

MCP with a focus on Between Abstraction, Dependencies, and Realism in Adoption

After studying the official specifications of the MCP protocol on our own, we found ourselves in a rather animated internal discussion (between code, repos, sushi, and, inevitably, post-lunch breaks 😅) where we reflected deeply on the true nature of this architectural model. The goal was to clarify some misunderstandings that I personally find recurrent in how the relationship between client and server is perceived in an MCP implementation.

Central Point

One of the ideas I have often heard repeated, and which I find misleading, is that the MCP client is some sort of “magical” entity, generic by definition. But that’s not the case. While the client may be built with a certain level of abstraction, it remains inherently dependent on the server it connects to. It must understand and be able to use the tools, prompts, and resources exposed by that server, integrating them into a well-defined operational flow. It is not a neutral black box, but rather an interpreter, fully aware of a language defined by the server.

The 3+1 Components of the MCP Server

At the core of the protocol, from the server side, there are three main components, plus a fourth optional but crucial one in more dynamic contexts: tools, prompts, resources, and resourceTemplate. These elements define what the server is capable of offering and in what form, and it is on these that the interaction with the client is built. Additionally, the server can notify the client whenever there is a change in the components it exposes.

Tools

The tools component is undoubtedly the most widely adopted today. It is also the one that makes clients appear more “generic,” as it allows tools exposed by the server to be directly and almost automatically integrated into an LLM. But the reality is that this represents only one part — a third, to be precise — of the entire MCP protocol. Limiting oneself to tools is convenient, sure, but it means losing much of the expressive and semantic power that MCP offers.

await session.initialize()

mcp_tools = await session.list_tools()
agent = Agent(tools=mcp_tools)

Resources

Resources are concrete contents, often documents or structured data, already known to the server. For the client, interacting with a resource simply means reading or consulting its content. There is no need for complex interpretation — just knowing where to find it and how to access it. This is a form of communication that is simple and direct, requiring little additional logic, but is therefore best suited for predictable, static scenarios.

embedder_model = ...          # e.g., SentenceTransformer, OpenAI, etc.
vector_database = ...         # e.g., FAISS, Chroma, Pinecone, etc.

resource_list = await session.list_resources()

for resource in resource_list.resources:
    metadata, content = await session.read_resource(resource.uri)

    if is_text(content):
        embedding = embedder_model.encode(content)
        vector_database.index(
            uri=resource.uri,
            embedding=embedding,
            metadata=metadata
        )
    else:
        # ❓ What is the structure or protocol of this content?
        # ❓ How should it be interpreted before encoding?
        # ❓ Does it represent structured data, binary, or something else?
        # ❓ Should this type be skipped, serialized, or transformed?
    pass

In that case, you need to know how to handle each resource type exposed by the server, or take a different approach depending on the content of the resource—even when only text is returned.

ResourceTemplate

When resourceTemplates come into play, however, the situation changes drastically. Here we are talking about content models that are not known in advance and must be populated dynamically. The result returned is not deterministic, but depends on the context, parameters, and inputs provided by the client. This requires a higher level of sophistication, as the client must not only invoke, but also orchestrate, validate, and sometimes even build the requests in real-time. At this point, the idea of a generic client crumbles: domain awareness, tailored logic, and a clear vision of what needs to be achieved are required.

# List available resource templates from the MCP server
resource_templates = client_session.list_resource_templates()

# URI Template: user://{userId}
#    → Requires: userId (e.g., "user://12345")
# URI Template: product://{sku}
#    → Requires: sku (e.g., "product://ABC123")
# URI Template: report://{year}/{month}
#    → Requires: year and month (e.g., "report://2024/03")
# URI Template: search://{query}
#    → Requires: query string (e.g., "search://climate%20change")

Resource templates define how to access a list of resources whose contents and count are not known a priori. Clients must treat responses as opaque until their format or protocol is explicitly understood. Like tools, templates allow clients to request context using input parameters, but the returned data is often larger and more complex. In this case, lazy indexing is likely the safer and more scalable default—though the best approach ultimately depends on the nature of the data and how the agent is intended to use it.

Prompts

Prompts represent the operational instructions or semantic contexts provided to the LLM for correctly using the server's functionalities. Here, we have two possible approaches that the standard doesn’t seem to clarify:

  1. Declarative Prompts and Conventions
    If we agree that prompts serve to clarify to the LLM how to use tools and resources, we can establish naming conventions (e.g., system_prefix, system_suffix…). In this case, a generic client can: load them dynamically, inject them into operational flows, modify or combine them according to known logics. The client becomes adaptable, but only if the server follows the same conventions.

  2. Prompts as Operational Logic
    If prompts are part of the operational logic of the flow (e.g., contextual prompts, custom, domain-dependent…), then: it is no longer possible to talk about a generic client. A dedicated client-side logic is required to manage them, interpret them, and integrate them into the flow's decisions.

prompts_result = await session.list_prompts()

resources_instructions = await session.get_prompt("resources_instructions", arguments={})
tools_instructions = await session.get_prompt("tools_instructions", arguments={})

system_prefix =  await session.get_prompt("system_prefix", arguments={})
resources_instructions = await session.get_prompt("resources_instructions", arguments={})
tools_instructions = await session.get_prompt("tools_instructions", arguments={})
system_suffix=  await session.get_prompt("system_suffix", arguments={})

agent_role = """{system_prefix}

You are a helpful and knowledgeable assistant. Use the available tools and resources effectively to provide clear, accurate, and context-aware responses.

Instructions for working with tools:
{tools_text}

Instructions for working with resources:
{resources_text}

{system_suffix}
"""

It is possible to use prompts in this way if the objective is clear and the client knows what to look for on the server, then mean a standardized naming convention. In this case it is even simpler: the prompt does not receive arguments, while knowledge of the domain is required to provide them.


The 2 Components of the MCP Client

Alongside what has been stated for the server, the MCP protocol defines two key concepts on the client side: sampling and roots. Both are essential for building advanced agent-based flows, where the client is not only a passive executor but also a controller of context and the model.

Sampling

Sampling allows the server to request LLM completions via the client, enabling more sophisticated logic and, above all, preserving security and privacy.

# server.py
@mcp.tool()
async def check_sampling_capability(prompt: str) -> str:
    """Request an LLM completion from the client."""
    context = mcp.get_context()
    sampling_message = SamplingMessage(
        role="user", content=TextContent(type="text", text=prompt)
    )
    response = await context.session.create_message(
        messages=[sampling_message], max_tokens=100
    )
    return response.content.text  # Assuming the response is a text message

# client.py
async def run():
    async with sse_client(MCP_SERVER_URL) as (read, write):
        async with ClientSession(
            read,
            write,
            [ ... ],
            sampling_callback=handle_sampling_message,
        ) as session:
            tool_result = await session.call_tool(
                "check_sampling_capability", {"prompt": "Hi Model!"}
            )
            print("Tool result:", tool_result)

Roots

Roots, on the other hand, define the boundaries within which the server is allowed to operate. It is the client that determines what is accessible by specifying these roots, effectively delimiting the scope of interaction. This makes the exchange not only powerful but also controllable. A root is a URI that the client suggests the server should focus on—similar to resources, but exposed by the client to the server.

# server.py
@mcp.tool()
async def check_roots_capability() -> list[str]:
    """Check if the client supports roots (filesystem access)."""
    context = mcp.get_context()

    response = await context.session.list_roots()
    roots = [root.uri for root in response.roots]

# client.py
async def run():
    async with sse_client(MCP_SERVER_URL) as (read, write):
        async with ClientSession(
            read,
            write,
            [ ... ],
            list_roots_callback=list_roots_callback
        ) as session:
            tool_result = await session.call_tool(
                "check_roots_capability", {}
            )
            print("Tool result:", tool_result)

Aside from retrieving the list of roots, it currently seems that a custom solution is needed if the server is to read root contents from the client. The protocol may assume that the server and client share filesystem access. So far, the only way I’ve found to access root contents from the server side is by using the sampling feature with some workarounds.


Intermediate Considerations

Bringing together what we've observed so far, it is clear that the MCP protocol is structured to promote strong interoperability between client and server. However, this interoperability — at least in the current version of the protocol — assumes a tight integration between the two parts. The fact that the server can notify the client whenever something changes in the components it exposes (such as resources, prompts, or tools), combined with the active role of the client, which, through roots and sampling, also exposes functionalities to the server, radically changes the traditional dynamics.

In my opinion, all of this weakens the classic distinction between client and server, bringing the MCP architecture closer to a peer-to-peer cooperation model, where both parties are active, aware, and participate in the execution logic. Indeed, in the JSON-RPC communication setup, the conventional roles of client and server can be flexible.

If this interpretation holds, then it’s natural, at least for me, to find the role of the client not only more interesting but also more challenging and satisfying from an implementation standpoint. Unfortunately, the current reality is that there are still fewer truly complete clients than servers.


MCP as a Protocol

In my view, MCP should be primarily interpreted as a communication protocol between agents and services. Its purpose is to separate the agent's logic (i.e., the client) from the service's logic (i.e., the server), establishing a common grammar that allows them to communicate coherently.

I’ve heard some objections regarding the absence of HTTP, which was recently introduced alongside WebSocket support. Personally, I believe HTTP is just one of the possible transport mediums: it is not essential, and often not even the most interesting.

The real value of MCP lies in the ability to build interoperable systems, where multiple clients can communicate with the same server and vice versa. However, this also comes with limits: it depends on which client functionalities the server is actually able to use. In this light, it makes sense that the first version of the protocol only allowed communication via stdio — a choice that is consistent with the interdependence between client and server that MCP currently presents.

In light of all this, we might even consider MCP similar to how we think about REST: a protocol that defines a shared interface exposed by the server, while clients remain purposeful, selective, and built around specific workflows. It’s not about generic compatibility — it’s about meaningful interaction, grounded in clear roles and expectations.

Be Careful of Misunderstandings

One thing I’ve found quite misleading is the way people talk about “registering a server.” In reality, what gets registered on the client side is not the server itself, but its capabilities: the tools, resources, and functionalities it exposes. It is the client's responsibility to orchestrate them consciously.

To say that a product is “MCP compatible” only makes sense if you consider the full spectrum of the components defined by the protocol. In practice, though, “MCP compatible” often just means importing some tools, which isn’t anything truly new: such an integration was already possible before through OpenAPI.


Conclusion

At the end of the day, the clients that today leverage all three components of the protocol — tools, resources, and prompts — are still the exception. Servers calling functionalities exposed by the client are even rarer. Most clients limit themselves to using tools, as they are easier to implement and offer immediate results. But by doing so, they miss the true potential of MCP, which is to be a standard designed to build intelligent, modular, and reusable agents.

I believe the key point here is this: intelligence doesn’t reside in the client itself, but in the operational flow the developer builds around prompts, resources, and tools, as well as in the client-server interoperability. That’s where the real value of MCP adoption lies. And perhaps, it’s also where its evolution will be shaped.

In this sense, we should start thinking of MCP a bit like we think of REST: a protocol that defines a common and generic interface, exposed by the server — but in which clients always have a specific purpose and use the APIs, in whole or in part, to achieve a well-defined functionality.

So, my conclusion is that it’s not enough to create an MCP server and hook it up to Cursor — which will probably only use what it deems useful, most likely just the tools — or another system labeled MCP compatible, to fully exploit the potential of the standard. What do you think?

Comments