Why Were MCP Servers and Agent Hosts Separated into Two?

Why Were MCP Servers and Agent Hosts Separated into Two?

From the moment I thought, “If we make it one, we’ll have one fewer project,” the process of solving the actual problem had already begun.

We decided to add an AI agent to our internal platform so that services could be operated using natural language. But the first question was not “What should we build?” but “How many things should we build?” Since we planned to place the conversational UI inside the existing service screens, we had to determine whether the MCP server and agent host could coexist in a single project.

Combining them seemed attractive. It would reduce the number of deployment units by one, eliminate interprocess communication, and remove the need to move back and forth between codebases. In fact, that was the direction we initially considered.

The conclusion was to separate them. It later became clear that this was not simply a question of how many projects to have. Questions such as where to place control and who should own the definition of the approval screen were all decided along this boundary.

This article summarizes the reasons for splitting the system into two projects and the trial and error involved in designing the control layer across that boundary. It covers a case where control was placed in two locations and the outer layer intercepted a better response from the inner layer, as well as a structure in which the layer drawing the approval screen owned its definition, causing silent failures to be reproduced.

1. Background to the Technology Choice — Why They Should Not Be Combined

The reason I could not make a decision was that it was unclear to me what MCP was intended for. When I examined that question directly, the answer became clear. MCP is a protocol for crossing process boundaries. If it is used within the same process, MCP becomes a structure that sends JSON-RPC to itself. In other words, once the components are integrated, there is no reason to use MCP in the first place.

From this perspective, the remaining reasons followed naturally.

  • The control principle does not hold. The idea of dual control—having the MCP server perform a final policy-based revalidation even after the host approves—only has meaning when the two layers are actually separated.

  • MCP servers are shared by multiple hosts. Not only internal agents but also external MCP clients connect to the same server. Once a server becomes dependent on a host, its reusability ends.

  • Their latency and cost characteristics are diametrically opposed. LLM calls are slow and expensive, while tool lookups are fast and inexpensive. Deploying them together causes them to hinder each other.

There was also an option that we eliminated during the review process: using an off-the-shelf agent SDK to reduce the number of projects. It did not fit for two reasons. Our internal stack is Java, while the SDK supported only Python and TypeScript. More importantly, it did not reduce the number of projects. Since components written in different languages cannot be combined, separation would actually be forced.

Another misconception was clarified during this discussion. Building an agent ourselves does not mean building the inference process ourselves. A general-purpose LLM always makes the decisions; what we build is the execution loop, tool integration, policies, and auditing. We therefore established the principle in one line: delegate inference to a general-purpose LLM, while the platform directly owns execution control.

2. Implementation Process and Details

2.1 Two Processes and the Allocation of Responsibilities

The architecture was finalized as two services. The MCP server handles the tool list and map, as well as policies, approvals, and auditing. The agent host handles conversation processing, LLM calls, and the execution loop. Both sides use Java 25 / Spring Boot 4.1.

What mattered here was not simply the fact that we split the processes, but deciding what should belong to which side after the split. If that decision is unstable, you end up with two processes but tangled responsibilities. In fact, that happened immediately.

2.2 Consolidating Ownership of Control — The Trap of Dual Control

While building tools that actually change something, such as an upload tool, we added an approval card for human confirmation before execution. The criteria were: “Do not make users enter IDs, do not ask follow-up questions, and make the entire process easier than using the screen.”

However, in the first implementation, calling a tool that required approval returned an error string instead of a card. The model received the string, explained it in natural language, and asked the user for an internal ID again. The card never appeared.

There were two causes. First, the system was throwing the approval wait as an exception. Second, the agent also had approval-blocking logic, preventing the MCP server from even having a chance to create a proposal. Because control existed redundantly in both layers, the outer layer intercepted the better response from the inner layer.

We therefore assigned ownership of the approval decision to the MCP server alone and made the agent pass it through. We also changed approval waiting to be returned normally as a structured execution proposal rather than as an error.

[Returning approval waiting as a proposal rather than an error]

// A pending approval is not an error. Returning it as a structure keeps the
// decision with the person, instead of letting the model narrate a failure.
static String of(String approvalId, MethodSignature signature, Object[] args,
                 McpTool mcpTool, NaviToolPolicy policy, ObjectNode form) {

    ObjectNode proposal = OBJECT_MAPPER.createObjectNode();
    proposal.put("proposalType", "approval.required");
    proposal.put("approvalId", approvalId);
    proposal.put("tool", mcpTool.name());
    proposal.put("risk", policy.risk());

    // 'arguments' keeps the original values for re-execution after approval.
    // 'fields' carries only what a person needs to read on the card.
    ObjectNode arguments = proposal.putObject("arguments");
    ArrayNode fields = proposal.putArray("fields");
    for (int i = 0; i < parameters.length && i < args.length; i++) {
        arguments.putPOJO(names[i], args[i]);
        if (isHidden(names[i])) continue;          // internal ids stay out of sight
        fields.addObject().put("label", label(parameters[i], names[i]))
                          .putPOJO("value", args[i]);
    }
    return proposal.toString();
}

Separating arguments and fields was the key to this design. The original arguments required for execution are preserved unchanged in arguments and used for re-execution after approval, while the card displays only the values that a person needs to confirm. Keys that users have no reason to know, such as internal IDs, are hidden.

However, hiding the arguments created a new problem. All of the upload tool’s arguments were internal IDs, so hiding them resulted in an empty card. We therefore added display-only parameters to the tool. The model fills them with names familiar to the user, and only those values appear on the card.

We applied the same principle to permission control. The roles required by the tool policy are declared, and the tool list is filtered according to the caller’s permissions when it is returned. Even if someone directly calls MCP and bypasses the listing step, the same check is performed once more immediately before execution.

[Once in the list, and once immediately before execution]

// The list is filtered by the caller's grants, but the check is repeated here:
// a client that bypasses tools/list must not bypass the policy.
if (StringUtils.hasText(toolPolicy.requiredRole())) {
    grant = grantResolver.resolveGrant(
            toolPolicy.executionLocation(), toolPolicy.requiredRole());
    if (grant == null) {
        String reason = "required role not granted: "
                + toolPolicy.requiredRole() + "@" + toolPolicy.executionLocation();
        audit(context, mcpTool.name(), riskLevel, false, reason);
        return denied(reason);
    }
}

2.3 Definitions Should Be Owned by the Side That Knows Them

After the approval card began working, one place remained where we had not applied the same principle. The list of editable fields used to modify values on the card was hard-coded in the frontend.

The problem with this structure is that failures are silent. If a tool adds a field or changes a field name and the frontend does not know about it, that field simply becomes uneditable. Since no error occurs, it remains that way until someone discovers it.

The cause was that the side rendering the form definition owned it. The tool owner is the side that actually knows which fields exist, but the contract was reversed. We therefore changed the system so that the MCP server creates forms for each tool-and-type combination and sends them together with the approval proposal, while the frontend only validates their shape and renders them. If they do not match, the fields fall back to read-only mode.

As a result, the frontend form schema file was reduced from 190 lines to 31 lines, and the structure now requires changes only on the server when fields are modified.

3. Lessons Learned Through Trial and Error

First, when control is placed redundantly in two layers, the outer layer intercepts the better response that the inner layer could produce. That was exactly what happened when the approval card failed to appear. We should have first decided to assign ownership of control to one place and delegate the rest. The intuition that “blocking something in two places must be safer” is correct for blocking, but wrong when the system should present something better instead of simply blocking.

Second, definitions should be owned not by the side that renders them, but by the side that knows them. We should have questioned the structure as soon as we saw that the frontend held the form schema. It was the same principle as the decision to centralize approval control in the server, but we had failed to apply it to the form. Even after establishing the same principle, applying it too narrowly allows the same type of failure to be reproduced in the gap.

Third, we must distinguish between what should be returned as an error and what should be returned as a normal response. Waiting for approval is not a failure; it is a state of waiting for a human decision. The moment it is thrown as an exception, the structure disappears and only a string remains, causing the model to interpret that string and explain it in words. If the model is explaining in sentences something that a person should decide with a button, the response format is usually wrong.

Fourth, “the feature is enabled” and “the feature is running” are different. During this period, native tool calling was enabled, but in practice the system was taking a different path every time. A colon in a map key in the configuration file caused binding to use an unintended key, and the lookup returned nothing, so the system silently took the fallback path. There was not a single line of error output. In systems with fallbacks, failures are silent, so you need the habit of checking the logs to verify that the path you believe you enabled is actually being taken.

Fifth, when discussing how to divide deployment units, we should examine transaction and database boundaries first. We once considered a proposal in which a separate MCP process would depend on and call the existing service directly. However, that service’s query layer was tied to transactions and persistence, which effectively meant launching the service one more time. It looked plausible in a diagram, but opening one section of code for a minute was enough to invalidate the proposal.

4. Results of the Implementation

The results of the decision to separate the components and the design of the control layer can be summarized as follows.

Item

If integrated

Result of separation

Role of MCP

JSON-RPC to itself — no reason remains to use the protocol

Actual communication across process boundaries

Control

Host and server in the same place — revalidation is meaningless

The server makes the final decision even after the host passes it

Reusability

Dedicated to a specific host

Multiple MCP clients sharing the same server

Approval response

Exception → string → model asks for the ID again

Structured proposal → frontend renders it as a card

Form definition

Frontend duplicated it (190 lines, silent failure)

Provided by the tool owner (31 lines)

In real-world validation, a single sentence—“Upload upload-test to the Global Gallery hub”—automatically chained listConnectedHubs → listCatalogKollexes → uploadKollexToHub. The approval card displayed only names familiar to the user, such as “Kollex: upload-test / Hub to upload to: Global Gallery,” and there were zero instances of internal ID leakage.

As a side benefit, we also addressed the response-size issue during this period. The query response included the icon image in its entirety, bringing the tool response to 1.1 MB, but the server recursively removed that field from the response, reducing it to approximately 5,900 characters. The original sent to the frontend remained unchanged; only the observation passed to the model was reduced.

5. Limitations and Future Plans

First, at this stage, approval extended only as far as displaying the card. The actual processing triggered by clicking the approval button—that is, issuing and validating an approval identifier and then continuing the original call—had not been implemented, so we honestly displayed “Approval functionality is under development” on the card. We later completed this part by using elicitation from the MCP standard, allowing the server to ask the person directly.

Second, the call-volume limit that had been specified as a gateway feature had not been implemented, and audit logs remained only in the application logs. Determining who owns a control and fully implementing that control are separate tasks.

Third, the control layer created here was shaken up twice more afterward. We made the authorization checks more sophisticated, then removed much of that work again, and the owner of approval ultimately moved further down the stack. We plan to document separately the basis on which the decision to remove what had been built was made.

What this decision confirmed was that the question of how many projects to create was actually a question of where to place responsibility. Dividing up the processes itself is easy. The difficult part was ensuring that, even after the division, each layer continued to bear only its own responsibilities. The three trial-and-error experiences discussed in this article were all cases in which those boundaries became misaligned once.

References

Junny

Site footer