Build an MCP Server That Asks Questions, Reports Progress, and Stops Safely
A hands-on Quarkus tutorial that teaches three MCP capabilities — elicitation, progress, and cancellation — by reconciling invoices against purchase orders.
An accounts payable clerk asks an AI assistant to reconcile May invoices from Acme Supplies. The assistant calls reconcile_invoices, the server finds 32 open invoices, and the matching work starts against purchase orders and goods receipts. Sounds like a typical enterprise use-case. But what happens when we add MCP to the mix? I see more and more “business applications” use the same agentic technologies to integrate backend systems to profit from the power of large language models. If we misuse MCP and treat a tool call like a business transaction this can quickly get us in trouble.
Do not treat a tool call like a business transaction. In accounts payable, that creates accounting trouble quickly. Reconciliation is analysis. It can run for a while, hit edge cases, and need human policy choices. Posting matched invoices to the ledger is irreversible. I keep those steps apart. For exactly the reason I stated earlier. Let’s use the features that MCP gives us already. We need: elicitation for missing business policy, progress for batch visibility, and cooperative cancellation when someone picks the wrong supplier.
The finished app exposes two MCP tools over Streamable HTTP, seeds an ACME May batch in PostgreSQL, and passes 18 integration tests that prove the separation.
What we build
This is a single-module Quarkus app on Java 25 with quarkus-mcp-server-http. MCP 1.12 comes through the platform BOM. PostgreSQL comes from Dev Services during ./mvnw test and quarkus:dev.
The app exposes two MCP tools at /mcp:
reconcile_invoices— long-running analysis that may ask the client for business policy, reports progress, and honors cancellation. Creates aREADY_FOR_REVIEWbatch. Never posts.post_reconciliation_batch— short, explicit posting step after human or agent review.
We use three MCP capabilities here:
Elicitation collects variance limits, missing-receipt handling, and default cost center before processing starts.
Progress reports five phase labels plus invoice-level updates throttled every eight invoices.
Cancellation stops the loop cooperatively; partial batches stay marked
CANCELLED.
The boundary between the two tools is the important part. reconcile_invoices talks to the reconciliation engine and streams progress back to the client. post_reconciliation_batch is the only path that marks invoices posted. Everything else stays in analysis.
What you need
JDK 25
Docker or Podman for PostgreSQL Dev Services
About ☕️☕️☕️ (yes, that’s a long one)
Three definitions used below:
Elicitation — the MCP server asks the client for structured input mid-tool-call. Here we use it for business policy, not OAuth consent.
Progress token — an optional client-supplied token that enables
notifications/progressupdates. No token means we skip progress traffic.Cooperative cancellation — the client sends a cancel signal; our Java code decides where to stop. Cancellation does not roll back lines already written to the batch.
Create the project
Start with a plain Quarkus app and the persistence extensions and follow along or grab the full source code from my Github repository.
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=dev.themainthread \
-DprojectArtifactId=invoice-reconciliation-mcp \
-Dextensions='hibernate-orm-panache,jdbc-postgresql' \
-DnoCode
cd invoice-reconciliation-mcpAdd the MCP server BOM next to the Quarkus platform BOM, then add the HTTP transport and test support:
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-mcp-server-bom</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency><dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-http</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-test</artifactId>
<scope>test</scope>
</dependency>Check Java 25 and Quarkus 3.36.1 in pom.xml:
<properties>
<maven.compiler.release>25</maven.compiler.release>
<quarkus.platform.version>3.36.1</quarkus.platform.version>
</properties>Model invoices, POs, and receipts
The domain model uses plain Panache entities. Each invoice stores a purchase order number. During reconciliation, we resolve the PO and goods receipt from that.
Create src/main/java/dev/themainthread/invoicerecon/domain/Invoice.java:
package dev.themainthread.invoicerecon.domain;
import java.math.BigDecimal;
import java.time.LocalDate;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
@Entity
@Table(name = "invoice")
public class Invoice extends PanacheEntity {
@Column(name = "invoice_number", nullable = false, unique = true)
public String invoiceNumber;
@Column(name = "supplier_id", nullable = false)
public String supplierId;
@Column(name = "invoice_date", nullable = false)
public LocalDate invoiceDate;
@Column(name = "purchase_order_number")
public String purchaseOrderNumber;
@Column(nullable = false)
public int quantity;
@Column(name = "unit_price", nullable = false)
public BigDecimal unitPrice;
@Column(name = "cost_center")
public String costCenter;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public InvoiceStatus status = InvoiceStatus.OPEN;
@Column(nullable = false)
public boolean posted;
public static java.util.List<Invoice> findOpenForSupplierAndPeriod(
String supplierId,
LocalDate from,
LocalDate to) {
return list(
"supplierId = ?1 and status = ?2 and invoiceDate >= ?3 and invoiceDate <= ?4 order by id",
supplierId,
InvoiceStatus.OPEN,
from,
to);
}
}Create src/main/java/dev/themainthread/invoicerecon/domain/PurchaseOrder.java:
package dev.themainthread.invoicerecon.domain;
import java.math.BigDecimal;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "purchase_order")
public class PurchaseOrder extends PanacheEntity {
@Column(name = "po_number", nullable = false, unique = true)
public String poNumber;
@Column(name = "supplier_id", nullable = false)
public String supplierId;
@Column(nullable = false)
public int quantity;
@Column(name = "unit_price", nullable = false)
public BigDecimal unitPrice;
public static PurchaseOrder findByPoNumber(String poNumber) {
return find("poNumber", poNumber).firstResult();
}
}Create src/main/java/dev/themainthread/invoicerecon/domain/GoodsReceipt.java:
package dev.themainthread.invoicerecon.domain;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "goods_receipt")
public class GoodsReceipt extends PanacheEntity {
@Column(name = "po_number", nullable = false)
public String poNumber;
@Column(name = "quantity_received", nullable = false)
public int quantityReceived;
public static GoodsReceipt findByPoNumber(String poNumber) {
return find("poNumber", poNumber).firstResult();
}
}Add the enums under src/main/java/dev/themainthread/invoicerecon/domain/:
package dev.themainthread.invoicerecon.domain;
public enum InvoiceStatus {
OPEN,
RECONCILED,
POSTED
}package dev.themainthread.invoicerecon.domain;
public enum ReconciliationStatus {
MATCHED,
PRICE_VARIANCE,
QUANTITY_VARIANCE,
MISSING_PURCHASE_ORDER,
MISSING_GOODS_RECEIPT,
CANCELLED
}package dev.themainthread.invoicerecon.domain;
public enum BatchStatus {
READY_FOR_REVIEW,
CANCELLED,
POSTED
}package dev.themainthread.invoicerecon.domain;
public enum MissingGoodsReceiptAction {
FLAG_FOR_REVIEW,
REJECT_INVOICE
}package dev.themainthread.invoicerecon.domain;
public enum ReconciliationOutcome {
COMPLETED,
CANCELLED,
ELICITATION_DECLINED,
ELICITATION_UNSUPPORTED,
NO_INVOICES_FOUND,
FAILED
}Policy, batch header, and batch lines
The policy record stores the values from elicitation. The batch header stores counts and status. Batch lines store the result for each invoice.
Create src/main/java/dev/themainthread/invoicerecon/policy/ReconciliationPolicy.java:
package dev.themainthread.invoicerecon.policy;
import dev.themainthread.invoicerecon.domain.MissingGoodsReceiptAction;
public record ReconciliationPolicy(
double maximumVariancePercent,
MissingGoodsReceiptAction missingGoodsReceiptAction,
boolean postMatchedInvoices,
String defaultCostCenter) {
public String idempotencyFragment() {
return maximumVariancePercent
+ "|"
+ missingGoodsReceiptAction
+ "|"
+ postMatchedInvoices
+ "|"
+ defaultCostCenter;
}
}Create src/main/java/dev/themainthread/invoicerecon/batch/ReconciliationBatch.java:
package dev.themainthread.invoicerecon.batch;
import java.time.LocalDate;
import dev.themainthread.invoicerecon.domain.BatchStatus;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
@Entity
@Table(name = "reconciliation_batch")
public class ReconciliationBatch extends PanacheEntity {
@Column(name = "batch_id", nullable = false, unique = true)
public String batchId;
@Column(name = "idempotency_key", nullable = false, unique = true)
public String idempotencyKey;
@Column(name = "supplier_id", nullable = false)
public String supplierId;
@Column(name = "from_date", nullable = false)
public LocalDate fromDate;
@Column(name = "to_date", nullable = false)
public LocalDate toDate;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public BatchStatus status;
@Column(nullable = false)
public int processed;
@Column(nullable = false)
public int matched;
@Column(nullable = false)
public int exceptions;
public static ReconciliationBatch findByBatchId(String batchId) {
return find("batchId", batchId).firstResult();
}
public static ReconciliationBatch findByIdempotencyKey(String idempotencyKey) {
return find("idempotencyKey", idempotencyKey).firstResult();
}
}Create src/main/java/dev/themainthread/invoicerecon/batch/ReconciliationLine.java:
package dev.themainthread.invoicerecon.batch;
import dev.themainthread.invoicerecon.domain.ReconciliationStatus;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "reconciliation_line")
public class ReconciliationLine extends PanacheEntity {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "batch_id")
public ReconciliationBatch batch;
@Column(name = "invoice_id", nullable = false)
public Long invoiceId;
@Column(name = "invoice_number", nullable = false)
public String invoiceNumber;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public ReconciliationStatus status;
}Build the reconciliation engine
ReconciliationService contains the matching rules and the batch loop. For each invoice, it checks that the PO exists, the goods receipt exists, the quantity matches, and the price stays inside the policy. The same loop also sends phase updates, throttles invoice progress, and stops cleanly when the client cancels.
Create src/main/java/dev/themainthread/invoicerecon/service/ReconciliationService.java:
package dev.themainthread.invoicerecon.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.jboss.logging.Logger;
import dev.themainthread.invoicerecon.batch.ReconciliationBatch;
import dev.themainthread.invoicerecon.batch.ReconciliationBatchRepository;
import dev.themainthread.invoicerecon.batch.ReconciliationBatchResult;
import dev.themainthread.invoicerecon.domain.BatchStatus;
import dev.themainthread.invoicerecon.domain.GoodsReceipt;
import dev.themainthread.invoicerecon.domain.Invoice;
import dev.themainthread.invoicerecon.domain.MissingGoodsReceiptAction;
import dev.themainthread.invoicerecon.domain.PurchaseOrder;
import dev.themainthread.invoicerecon.domain.ReconciliationOutcome;
import dev.themainthread.invoicerecon.domain.ReconciliationStatus;
import dev.themainthread.invoicerecon.policy.ReconciliationPolicy;
import io.quarkiverse.mcp.server.Cancellation;
import io.quarkiverse.mcp.server.Cancellation.OperationCancellationException;
import io.quarkiverse.mcp.server.Progress;
import io.quarkiverse.mcp.server.ProgressTracker;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class ReconciliationService {
private static final Logger LOG = Logger.getLogger(ReconciliationService.class);
private static final int PROGRESS_INTERVAL = 8;
@Inject
ReconciliationBatchRepository batchRepository;
@Inject
BatchIdGenerator batchIdGenerator;
public ReconciliationStatus reconcileInvoice(Invoice invoice, ReconciliationPolicy policy) {
if (invoice.purchaseOrderNumber == null || invoice.purchaseOrderNumber.isBlank()) {
return ReconciliationStatus.MISSING_PURCHASE_ORDER;
}
PurchaseOrder purchaseOrder = PurchaseOrder.findByPoNumber(invoice.purchaseOrderNumber);
if (purchaseOrder == null) {
return ReconciliationStatus.MISSING_PURCHASE_ORDER;
}
GoodsReceipt goodsReceipt = GoodsReceipt.findByPoNumber(invoice.purchaseOrderNumber);
if (goodsReceipt == null) {
if (policy.missingGoodsReceiptAction() == MissingGoodsReceiptAction.REJECT_INVOICE) {
return ReconciliationStatus.MISSING_GOODS_RECEIPT;
}
return ReconciliationStatus.MISSING_GOODS_RECEIPT;
}
if (invoice.quantity != purchaseOrder.quantity) {
return ReconciliationStatus.QUANTITY_VARIANCE;
}
BigDecimal variancePercent = variancePercent(invoice.unitPrice, purchaseOrder.unitPrice);
if (variancePercent.doubleValue() > policy.maximumVariancePercent()) {
return ReconciliationStatus.PRICE_VARIANCE;
}
if (invoice.costCenter == null || invoice.costCenter.isBlank()) {
invoice.costCenter = policy.defaultCostCenter();
}
return ReconciliationStatus.MATCHED;
}
@Transactional
public ReconciliationBatchResult runBatch(
String supplierId,
LocalDate from,
LocalDate to,
ReconciliationPolicy policy,
Progress progress,
Cancellation cancellation) {
String idempotencyKey = ReconciliationBatchRepository.buildIdempotencyKey(supplierId, from, to, policy);
ReconciliationBatch existing = batchRepository.findByIdempotencyKey(idempotencyKey);
if (existing != null) {
return ReconciliationBatchResult.fromBatch(existing, ReconciliationOutcome.COMPLETED);
}
List<Invoice> invoices = Invoice.findOpenForSupplierAndPeriod(supplierId, from, to);
if (invoices.isEmpty()) {
return ReconciliationBatchResult.message(
ReconciliationOutcome.NO_INVOICES_FOUND,
"No open invoices matched supplier " + supplierId + " between " + from + " and " + to);
}
ReconciliationBatch batch = batchRepository.createBatch(
batchIdGenerator.nextBatchId(),
idempotencyKey,
supplierId,
from,
to,
BatchStatus.READY_FOR_REVIEW);
ProgressTracker invoiceTracker = buildInvoiceTracker(progress, invoices.size());
AtomicInteger phase = new AtomicInteger(1);
try {
reportPhase(progress, phase.get(), "Loading open invoices");
reportPhase(progress, phase.incrementAndGet(), "Matching purchase orders");
int index = 0;
for (Invoice invoice : invoices) {
cancellation.skipProcessingIfCancelled();
ReconciliationStatus status = reconcileInvoice(invoice, policy);
batchRepository.addLine(batch, invoice.id, invoice.invoiceNumber, status);
index++;
if (invoiceTracker != null && index % PROGRESS_INTERVAL == 0) {
invoiceTracker.advanceAndForget();
}
}
reportPhase(progress, phase.incrementAndGet(), "Checking goods receipts");
reportPhase(progress, phase.incrementAndGet(), "Applying variance rules");
reportPhase(progress, phase.incrementAndGet(), "Generating reconciliation report");
LOG.infov(
"Reconciliation batch {0} completed: processed={1}, matched={2}, exceptions={3}",
batch.batchId,
batch.processed,
batch.matched,
batch.exceptions);
return ReconciliationBatchResult.fromBatch(batch, ReconciliationOutcome.COMPLETED);
} catch (OperationCancellationException ex) {
batchRepository.markCancelled(batch);
LOG.infov("Reconciliation batch {0} cancelled after {1} invoices", batch.batchId, batch.processed);
return ReconciliationBatchResult.fromBatch(batch, ReconciliationOutcome.CANCELLED);
} catch (RuntimeException ex) {
LOG.errorv(ex, "Reconciliation batch {0} failed", batch.batchId);
batchRepository.markCancelled(batch);
return ReconciliationBatchResult.message(
ReconciliationOutcome.FAILED,
"Reconciliation failed: " + ex.getMessage());
}
}
private ProgressTracker buildInvoiceTracker(Progress progress, int total) {
if (progress == null || progress.token().isEmpty()) {
return null;
}
return progress.trackerBuilder()
.setTotal(total)
.setDefaultStep(PROGRESS_INTERVAL)
.setMessageBuilder(current -> "Reconciled " + current + " of " + total + " invoices")
.build();
}
private void reportPhase(Progress progress, int phaseNumber, String label) {
if (progress == null || progress.token().isEmpty()) {
return;
}
progress.notificationBuilder()
.setProgress(phaseNumber)
.setTotal(5)
.setMessage("Phase " + phaseNumber + " of 5: " + label)
.build()
.sendAndForget();
}
private BigDecimal variancePercent(BigDecimal invoicePrice, BigDecimal poPrice) {
if (poPrice.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
return invoicePrice.subtract(poPrice)
.abs()
.multiply(BigDecimal.valueOf(100))
.divide(poPrice, 4, RoundingMode.HALF_UP);
}
}Expose the MCP tools
InvoiceReconciliationTools is where the MCP integration enters the app. This is where elicitation, progress, and cancellation show up. Both tool methods carry @Blocking because they run JTA transactions on worker threads.
Create src/main/java/dev/themainthread/invoicerecon/mcp/InvoiceReconciliationTools.java:
package dev.themainthread.invoicerecon.mcp;
import java.time.LocalDate;
import org.jboss.logging.Logger;
import dev.themainthread.invoicerecon.batch.ReconciliationBatchResult;
import dev.themainthread.invoicerecon.domain.MissingGoodsReceiptAction;
import dev.themainthread.invoicerecon.domain.ReconciliationOutcome;
import dev.themainthread.invoicerecon.policy.ReconciliationPolicy;
import dev.themainthread.invoicerecon.service.PostingService;
import dev.themainthread.invoicerecon.service.ReconciliationService;
import io.quarkiverse.mcp.server.Cancellation;
import io.quarkiverse.mcp.server.Elicitation;
import io.quarkiverse.mcp.server.ElicitationRequest;
import io.quarkiverse.mcp.server.Progress;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.smallrye.common.annotation.Blocking;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class InvoiceReconciliationTools {
private static final Logger LOG = Logger.getLogger(InvoiceReconciliationTools.class);
@Inject
ReconciliationService reconciliationService;
@Inject
PostingService postingService;
@Blocking
@Tool(
name = "reconcile_invoices",
description = """
Reconciles open supplier invoices against purchase orders and goods receipts.
The tool may request missing reconciliation rules from the client.
""")
public String reconcileInvoices(
@ToolArg(description = "Supplier identifier") String supplierId,
@ToolArg(description = "Start date in ISO-8601 format") LocalDate from,
@ToolArg(description = "End date in ISO-8601 format") LocalDate to,
Elicitation elicitation,
Progress progress,
Cancellation cancellation) {
if (!elicitation.isSupported()) {
return ReconciliationResultJson.toJson(
ReconciliationBatchResult.message(
ReconciliationOutcome.ELICITATION_UNSUPPORTED,
"Client does not support elicitation; reconciliation policy is required"));
}
ElicitationRequest request = elicitation.requestBuilder()
.setMessage("Provide the invoice reconciliation policy")
.addSchemaProperty(
"maximumVariancePercent",
ElicitationRequest.NumberSchema.builder()
.setTitle("Maximum price variance")
.setDescription("Invoices above this percentage are flagged")
.setMinimum(0)
.setMaximum(20)
.setDefaultValue(2.5)
.build())
.addSchemaProperty(
"defaultCostCenter",
ElicitationRequest.StringSchema.builder()
.setTitle("Default cost center")
.setDescription("Used when the invoice has no cost center")
.setMinLength(3)
.setMaxLength(30)
.setDefaultValue("FIN-OPERATIONS")
.build())
.addSchemaProperty(
"postMatchedInvoices",
ElicitationRequest.BooleanSchema.builder()
.setTitle("Post matched invoices")
.setDescription("Preference only; posting happens in post_reconciliation_batch")
.setDefaultValue(false)
.build())
.addSchemaProperty(
"missingGoodsReceiptAction",
ElicitationRequest.StringSchema.builder()
.setTitle("Missing goods receipt")
.setDescription("Use FLAG_FOR_REVIEW or REJECT_INVOICE")
.setDefaultValue("FLAG_FOR_REVIEW")
.build())
.build();
var response = request.sendAndAwait();
if (!response.actionAccepted()) {
LOG.info("Reconciliation policy elicitation declined");
return ReconciliationResultJson.toJson(ReconciliationBatchResult.message(
ReconciliationOutcome.ELICITATION_DECLINED,
"Reconciliation policy was not provided"));
}
ReconciliationPolicy policy = new ReconciliationPolicy(
response.content().getNumber("maximumVariancePercent").doubleValue(),
parseMissingGoodsReceiptAction(response.content().getString("missingGoodsReceiptAction")),
response.content().getBoolean("postMatchedInvoices"),
response.content().getString("defaultCostCenter"));
ReconciliationBatchResult result = reconciliationService.runBatch(
supplierId,
from,
to,
policy,
progress,
cancellation);
return ReconciliationResultJson.toJson(result);
}
@Blocking
@Tool(
name = "post_reconciliation_batch",
description = "Posts matched invoices from an approved reconciliation batch to the ledger")
public String postReconciliationBatch(@ToolArg(description = "Reconciliation batch identifier") String batchId) {
return postingService.postBatch(batchId);
}
private MissingGoodsReceiptAction parseMissingGoodsReceiptAction(String value) {
if (value == null || value.isBlank()) {
return MissingGoodsReceiptAction.FLAG_FOR_REVIEW;
}
return MissingGoodsReceiptAction.valueOf(value);
}
}The version above always starts with elicitation. Once you wire real MCP clients, add the optional inline policy arguments, resolvePolicy, and the fallbacks from MCP client compatibility.
Posting stays in a separate service so the reconcile path cannot touch ledger state.
Create src/main/java/dev/themainthread/invoicerecon/service/PostingService.java:
package dev.themainthread.invoicerecon.service;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import dev.themainthread.invoicerecon.batch.ReconciliationBatch;
import dev.themainthread.invoicerecon.batch.ReconciliationBatchRepository;
import dev.themainthread.invoicerecon.batch.ReconciliationLine;
import dev.themainthread.invoicerecon.domain.BatchStatus;
import dev.themainthread.invoicerecon.domain.Invoice;
import dev.themainthread.invoicerecon.domain.InvoiceStatus;
import dev.themainthread.invoicerecon.domain.ReconciliationStatus;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class PostingService {
private static final Logger LOG = Logger.getLogger(PostingService.class);
@Inject
ReconciliationBatchRepository batchRepository;
@ConfigProperty(name = "invoice-reconciliation.posting.enabled", defaultValue = "true")
boolean postingEnabled;
@Transactional
public String postBatch(String batchId) {
if (!postingEnabled) {
throw new IllegalStateException("Posting is disabled by configuration");
}
ReconciliationBatch batch = batchRepository.findByBatchId(batchId);
if (batch == null) {
throw new IllegalArgumentException("Unknown reconciliation batch: " + batchId);
}
if (batch.status == BatchStatus.POSTED) {
return "Batch " + batchId + " was already posted";
}
if (batch.status != BatchStatus.READY_FOR_REVIEW) {
throw new IllegalStateException("Batch " + batchId + " is not ready for posting (status=" + batch.status + ")");
}
int postedCount = 0;
for (ReconciliationLine line : batchRepository.linesForBatch(batch)) {
if (line.status != ReconciliationStatus.MATCHED) {
continue;
}
Invoice invoice = Invoice.findById(line.invoiceId);
if (invoice == null) {
continue;
}
invoice.status = InvoiceStatus.POSTED;
invoice.posted = true;
postedCount++;
}
batchRepository.markPosted(batch);
LOG.infov("Posted {0} matched invoices from batch {1}", postedCount, batchId);
return "Posted " + postedCount + " matched invoices from batch " + batchId;
}
}Configure the app
Put this in src/main/resources/application.properties:
quarkus.application.name=invoice-reconciliation-mcp
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
quarkus.hibernate-orm.sql-load-script=import.sql
quarkus.mcp.server.http.root-path=/mcp
invoice-reconciliation.posting.enabled=trueDuring ./mvnw test and quarkus:dev, Dev Services starts PostgreSQL. Hibernate recreates the schema and loads import.sql.
Seed ACME May invoices
import.sql loads 30 purchase orders, 28 goods receipts, and 32 open invoices for supplier ACME dated May 2026. I kept the batch small so the tutorial and tests stay fast. Production batches can be much larger, and the same throttling pattern still works there.
With the default policy (2.5% maximum variance, flag missing receipts), the seed produces predictable counts:
processed: 32
matched: 24
exceptions: 8
The eight exceptions break down like this:
INV-025 through INV-028 — unit price 103.00 against PO price 100.00 (3% variance, above the 2.5% limit)
INV-029 and INV-030 — PO-029 and PO-030 have no goods receipt row
INV-031 and INV-032 — purchase order numbers
PO-MISSING-1andPO-MISSING-2do not exist
Twenty-four invoices match. That gives us useful JSON output without slowing the test suite. Grab the full import.sql from my Github repository.
Ask for business policy
The prompt alone is not enough to reconcile every invoice. We still need to decide how much price variance is acceptable, what to do when a goods receipt is missing, and which cost center receives unassigned lines.
Those are business rules. I do not want an agent guessing them. Elicitation handles this: the server sends a structured form, the client renders it, the user answers, and processing continues.
The reconcile_invoices tool builds these fields:
Maximum price variance — number, 0–20, default 2.5. Invoices above this percentage become
PRICE_VARIANCE.Default cost center — string, 3–30 characters, default
FIN-OPERATIONS. Applied when the invoice has no cost center.Post matched invoices — boolean, default false. Preference only; actual posting happens in
post_reconciliation_batch.Missing goods receipt — string enum, default
FLAG_FOR_REVIEW. AcceptFLAG_FOR_REVIEWorREJECT_INVOICE.
This form captures policy. It is not an approval screen. If someone ticks “post matched invoices”, they are stating a preference. That does not grant ledger permission. Keep real authorization in PostingService and in your identity layer.
If the client declines the form, the tool returns ELICITATION_DECLINED and creates no batch. Some clients do not support the form at all, so the tool also accepts optional inline policy arguments (maximumVariancePercent, defaultCostCenter, postMatchedInvoices, missingGoodsReceiptAction). When those are omitted, the server uses the same defaults as the elicitation form: 2.5% variance, FIN-OPERATIONS, and FLAG_FOR_REVIEW.
Report batch progress without flooding the client
Long batches need visibility. They do not need a protocol notification for every invoice.
When the client sends a progress token, ReconciliationService emits two kinds of updates:
Phase labels — five fixed stages the AP clerk can read at a glance:
Phase 1 of 5: Loading open invoices
Phase 2 of 5: Matching purchase orders
Phase 3 of 5: Checking goods receipts
Phase 4 of 5: Applying variance rules
Phase 5 of 5: Generating reconciliation report
Invoice counter — ProgressTracker advances every eight invoices: “Reconciled 8 of 32 invoices”, then 16, 24, 32. With 32 seed invoices, that means four updates. At 240 invoices I would keep the same interval or widen it to every 20 or 25. The goal is useful feedback without extra protocol traffic.
If the client sends no progress token, we skip the notifications. The tool still runs; the client just loses live updates.
Stop cooperative processing
Cancellation is cooperative. The MCP server receives the cancel signal from the client. Our code calls cancellation.skipProcessingIfCancelled() at the top of each invoice iteration. That is where we decide to stop.
Before you run the cancellation test, predict the outcome: if the client cancels after 12 of 32 invoices, what should processed and status show?
The answer is processed=12, status=CANCELLED, and outcome=CANCELLED. The twelve lines already written stay in the batch. Nothing was posted because reconcile never calls PostingService.
Cancellation stops future work. The analysis lines already written remain recorded. That is another reason I keep posting in a separate tool. Cancel the long reconcile job and you get a partial batch, but the ledger stays untouched.
Keep analysis and posting separate
Accounts payable should stay in two stages: reconcile, review, then post. I do not want one tool that does both.
Look back at the architecture diagram: reconcile_invoices feeds the reconciliation engine and streams progress. Only the post_reconciliation_batch path reaches PostingService and marks invoices posted.
Why is post_reconciliation_batch a separate tool instead of a boolean on reconcile? Because an MCP tool call is not a business transaction. It can run for minutes, get cancelled, and leave a proposed batch. Posting is short and irreversible, so it needs a separate step after review.
The elicitation form labels postMatchedInvoices as a preference. Reconcile never uses it to write the ledger.
Production concerns
Even this simple example has four production concerns.
Idempotency. If reconcile_invoices runs twice with the same supplier, date range, and policy, it must not create duplicate batches. ReconciliationBatchRepository.buildIdempotencyKey hashes those inputs plus the policy fragment. A unique database constraint on idempotency_key backs the guard. The second call returns the existing batch.
@Blocking for JTA. MCP tools start on the reactive event loop. Hibernate and Narayana JTA want worker threads. Both tool methods carry @Blocking so the transaction work runs on worker threads, where it belongs.
Elicitation is not approval. The policy form collects business rules. It says nothing about who may post to the general ledger. PostingService checks batch status and respects invoice-reconciliation.posting.enabled. In production, wire real authorization there.
Progress accuracy. One hundred percent progress here means analysis finished. It says nothing about posting. If you add posting progress, that is a different tool and a different contract.
MCP client compatibility
MCP clients do not all implement the same capabilities. I tested this demo with Cursor and Bob. Two differences matter here.
Elicitation support varies
Cursor can render the mid-call policy form from the elicitation capability. Bob connects to Streamable HTTP, calls tools/call, and does not handle interactive prompts.
My first version returned ELICITATION_UNSUPPORTED when elicitation was missing. That is fine for a tutorial. For a real agent trying to reconcile May invoices with only supplierId, from, and to, the request cannot continue.
InvoiceReconciliationTools.resolvePolicy now picks a path in order:
Inline policy arguments — if the caller passes any of
maximumVariancePercent,defaultCostCenter,postMatchedInvoices, ormissingGoodsReceiptAction, buildReconciliationPolicyfrom those values. Unset fields use the same defaults as the elicitation form.Elicitation — if the client supports it and no inline policy was passed, show the structured form.
Tutorial defaults — if elicitation is unsupported and no inline policy was passed, apply 2.5% variance,
FIN-OPERATIONS,FLAG_FOR_REVIEW, andpostMatchedInvoices=false.
Clients that support elicitation still get the prompt when they omit the optional arguments. Bob can still reconcile with the three required fields.
Supplier IDs are not natural language
The clerk says “Acme Supplies.” The ERP stores ACME. Bob guessed acme-supplies and received NO_INVOICES_FOUND with processed: 0. The server was correct. The agent was using the supplier name the way people usually do.
SupplierIdResolver runs before the invoice query. It normalizes aliases by stripping punctuation and case-folding (acme-supplies → acmesupplies → ACME). It also matches case-insensitively against supplier IDs already present in the database. When nothing matches, the NO_INVOICES_FOUND message appends Known supplier IDs: ACME so the agent can self-correct. This might not be a feasible way to resolve this problem if we look at this from a security and data privacy perspective. But it is worth thinking about!
The supplierId @ToolArg description also names the seed value: ACME for Acme Supplies.
Create src/main/java/dev/themainthread/invoicerecon/policy/SupplierIdResolver.java:
@ApplicationScoped
public class SupplierIdResolver {
private static final Map<String, String> ALIASES = Map.of(
"acme", "ACME",
"acmesupplies", "ACME");
public String resolve(String supplierId) {
String trimmed = supplierId.trim();
String alias = ALIASES.get(normalize(trimmed));
if (alias != null) {
return alias;
}
for (String knownSupplierId : knownSupplierIds()) {
if (knownSupplierId.equalsIgnoreCase(trimmed)) {
return knownSupplierId;
}
}
return trimmed;
}
private static String normalize(String value) {
return value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
}
}ReconciliationService.runBatch calls supplierIdResolver.resolve(supplierId) before Invoice.findOpenForSupplierAndPeriod.
Prove it
From the module root:
./mvnw testAll 18 tests should pass. The suite covers domain matching, batch counts, MCP tool listing, elicitation accept and decline, non-elicitation fallback, supplier alias resolution, progress monotonicity, cancellation at invoice 12, posting separation, and idempotent duplicate batches.
The tests lock in nine things:
Missing policy triggers elicitation before reconciliation starts
Accepting the form runs reconciliation and creates a
READY_FOR_REVIEWbatchDeclining the form returns
ELICITATION_DECLINEDand creates no batchProgress values increase monotonically and never move backwards
Cancellation after 12 invoices marks the batch
CANCELLEDwithprocessed=12Reconcile never sets
invoice.posted=trueDuplicate reconcile calls return the same
batchIdwithout a second batch rowClients without elicitation support reconcile with tutorial defaults or explicit inline policy arguments
Supplier aliases such as
acme-suppliesresolve to the seeded ERP idACME
Expected counts for supplier ACME, May 2026, with default policy:
processed: 32
matched: 24
exceptions: 8
status: READY_FOR_REVIEWWhen you are debugging one area, run the relevant test directly:
./mvnw test -Dtest=InvoiceReconciliationMcpTest
./mvnw test -Dtest=ElicitationDeclinedMcpTest
./mvnw test -Dtest=ProgressTrackingTest
./mvnw test -Dtest=ReconciliationServiceTest
./mvnw test -Dtest=PostingServiceTestOptional: Wire Cursor or Bob
If you want to call the tools from a real MCP client while quarkus:dev is running, point it at Streamable HTTP on /mcp. Cursor and Bob both work. Bob uses the compatibility fallbacks from the previous section.
For Cursor, add a .mcp.json at the project root:
{
"mcpServers": {
"invoice-reconciliation": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}Start dev mode:
./mvnw quarkus:devCursor connects to Streamable HTTP at /mcp and can answer elicitation prompts. Bob connects the same way but relies on inline policy arguments or the tutorial defaults. The integration tests are still the main verification path. Client wiring is only for manual testing.
Close
We now have an invoice reconciliation MCP server where analysis and posting stay separate. Elicitation collects business policy before the batch loop starts. Progress reports phase labels and throttled invoice counts. Cancellation stops the analysis run without touching the ledger.
That boundary matters more than the rest of the implementation. An MCP tool call can propose work, collect input, and stream progress. Financial state should move only in a separate explicit step.




