Serve the semantic layer over MCP

About 24 minutes to read, with 5 examples in 7 sections.

Edition 2026.11.3 Examples passed Nov 6, 2026 78 of 78 examples pass What changed

Written with AI. Verified by people. How we make books

Anyone can check this book or correct it. Report an error · How to fix one

Labs

Learn this chapter by doing it

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Opens in about 30 seconds.

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Included with membership: 20 hours a month with Member, or 60 with Member Plus.

Open in Lab, in a new tab

Checking your hours…

Checking for your Lab…

Every chapter is read aloud for members. Chapter 1 of every book is free for everyone. Become a member, or sign in.

Read by an AI voice, word for word from this edition's text. Code, output and tables are on the page.

Pocket TTS by Kyutai, voice alba, from Alba MacKenna's recordings, CC BY 4.0.Pocket TTS by Kyutai, voice eve, from VCTK corpus speaker p361, CSTR, University of Edinburgh, CC BY 4.0, adapted by Kyutai.

By the end of this chapter, you can serve the shop's governed metrics as MCP tools that answer for the role the server was built for, return each answer with its provenance as structured data, test the server in memory, and run the book's agent through it as a host.

The shop is the online outdoor-gear shop in three regions that this book follows throughout. Its agent so far lives inside the book's Python: chapter 6 gave it five metric tools over the semantic layer, and chapter 9 put each role's row filters, column masks and minimum group size underneath them, in lib/policies.py. People in the shop don't work inside that Python. They ask questions in the assistant they already use, a chat app on their desktop or an editor with an agent built in. Each of those could get its own copy of the tools, and then each copy would need its own copy of the access rules, kept in step by hand. The first copy that drifts gives a regional lead another region's revenue.

The Model Context Protocol (MCP) removes the copies. It is an open protocol in which an application, the host, runs a client for each server it connects to, and a server offers tools that the host's model can call. Model Context Protocol in Practice covers the protocol, from host, client and server to authorization, and this chapter links to it rather than teaching it again. Here, the shop's semantic layer becomes one MCP server, which this chapter calls the semantic server, in lib/semantic_server.py. It offers the same five tools as the in-process agent, through chapter 9's PolicyToolbox, the metric tools with the role's access rules underneath, so every host that connects gets the same metrics and the same rules. The examples use the Python SDK, mcp 2.3.0, which speaks the specification's revision 2026-07-28, and every one of them runs in memory, with no port and no process.

Turn metrics into tools

An MCP tool is a name, a description the model reads, a JSON Schema for its arguments and, optionally, a schema for its result. A client discovers a server's tools with tools/list and calls one with tools/call. The specification says the list "MUST NOT vary per-connection" but "MAY vary by the authorization presented on the request", and that servers "SHOULD return tools in a deterministic order" (tools). So the questions for a semantic layer are which tools to offer, and whether a role should see fewer of them.

We recommend offering the tools the agent already has. Chapter 6 chose five for the shop's agent: list_metrics, describe_metric, query_metric, ask_back and cannot_answer. They were measured on the golden questions, whose answers were checked by running them, and they ask the model to choose a metric request, never to write SQL. build_server() in lib/semantic_server.py registers one MCP tool for each, with a description the model reads and typed arguments, and each tool body calls the same method of chapter 9's PolicyToolbox that the in-process agent calls. The tools are registered in name order, so tools/list returns the same order every time. Each is marked read-only, idempotent and closed-world with MCP's tool annotations. Model Context Protocol in Practice explains how to write descriptions the model reads and why annotations are hints, not guarantees.

The example builds a server for the analyst, who sees everything, and one for the support team, who may see no revenue. It lists each server's tools, calls list_metrics on each, and compares the catalog with the one the in-process agent puts in its prompt.

list-metric-tools.py Python 3.12.15 · anyio 4.16.0 · duckdb 2.0.0 · mcp 2.3.0 · pydantic 2.14.2 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""The semantic layer as an MCP server: the tools it lists, what query_metric takes, and each role's catalog."""import anyiofrom mcp import Clientfrom lib.metric_tools import catalogfrom lib.policies import PolicyLayer, policy_forfrom lib.semantic import loadfrom lib.semantic_server import build_serverfrom lib.warehouse import connectcon, layer = connect(), load()async def main():    listed = {}    for role in ("analyst", "support"):        async with Client(build_server(role, shop=con, layer=layer)) as client:   # in memory: no port, no process            tools = (await client.list_tools()).tools            text = (await client.call_tool("list_metrics", {})).structured_content["text"]        listed[role] = [t.name for t in tools]        metrics = [line[2:].split(":")[0] for line in text.splitlines()]        print(f"{role}, tools: {', '.join(listed[role])}")        print(f"{role}, metrics in list_metrics: {len(metrics)}")        print(f"{role}, metrics: {', '.join(metrics)}")        in_process = catalog(PolicyLayer(layer, policy_for(role), con))        print(f"{role}, same catalog as the in-process agent: {text == in_process}")    query = next(t for t in tools if t.name == "query_metric")    print(f"query_metric inputs: {', '.join(query.input_schema['properties'])}")    print(f"query_metric requires: {', '.join(query.input_schema['required'])}")    print(f"tools marked read-only: {sum(t.annotations.read_only_hint for t in tools)} of {len(tools)}")    print(f"tools with a role input: {sum('role' in t.input_schema['properties'] for t in tools)}")    print(f"same tool list for both roles: {listed['analyst'] == listed['support']}")anyio.run(main)
Outputpython list-metric-tools.py
analyst, tools: ask_back, cannot_answer, describe_metric, list_metrics, query_metric
analyst, metrics in list_metrics: 10
analyst, metrics: net_revenue, gross_revenue, orders, line_net_revenue, units_sold, refunds, order_refunds, new_customers, refund_rate, average_order_value
analyst, same catalog as the in-process agent: True
support, tools: ask_back, cannot_answer, describe_metric, list_metrics, query_metric
support, metrics in list_metrics: 5
support, metrics: orders, units_sold, refunds, order_refunds, new_customers
support, same catalog as the in-process agent: True
query_metric inputs: metrics, group_by, filters, order_by, limit
query_metric requires: metrics
tools marked read-only: 5 of 5
tools with a role input: 0
same tool list for both roles: True

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

Both servers list the same tools: ask_back, cannot_answer, describe_metric, list_metrics, query_metric. What differs is the catalog. The analyst's list_metrics returns 10 metrics and support's 5: orders, units_sold, refunds, order_refunds, new_customers. Every revenue metric is gone from support's catalog, because chapter 9's PolicyLayer hides any metric that reads a column the role is denied. For both roles, the catalog over MCP is the same text the in-process agent puts in its prompt (True for support). That matters for the host: an unchanged catalog means an unchanged prompt.

query_metric takes metrics, group_by, filters, order_by, limit and requires only metrics. Of the server's tools, 5 of 5 are marked read-only, and 0 take an argument called role, which is the subject of the next section.

Keeping one tool list for every role was a choice. The specification would allow support's server to list fewer tools, but each of the five tools is useful to every role, and the role changes what a tool returns, not whether it exists. A role that can't use a tool at all is the case for shortening the list, and Model Context Protocol in Practice shows a list that varies by what the token allows in scope tools by what the token allows. The cost of a long list is a separate matter, which the same book takes up in keep the tool list small.

Working rule: Serve the tools your agent was measured with, each one a thin call into the same code the in-process agent uses, so that the server can't drift from what you tested. List them in a fixed order and mark them read-only. Let the role change what a tool returns, and shorten the tool list only when a role can't use a tool at all.

Carry the caller's role into every query

A server that enforces chapter 9's access rules has to know whose rules to enforce, and that's the part an MCP server most often gets wrong. Revision 2026-07-28 has no protocol-level sessions: it removed the initialize handshake and the Mcp-Session-Id header, so every request stands alone (changelog). There's no session to hold a role. The specification gives two places for identity instead. A server that a host launches over stdio, for one person, "SHOULD NOT" use MCP's authorization and should "retrieve credentials from the environment" (authorization). A server on HTTP derives the user from a verified access token: the specification's security guidance, on binding a server's state to a user, derives "the user ID … from the verified token rather than supplied by the client" (security best practices).

The tempting third place is a tool argument: give query_metric a role parameter and let the host fill it in. Then the role is whatever the caller sends, and the caller is a model that reads questions and, as chapter 9 showed, text inside the data. The example builds three servers and sends each the same request: gq-017, net revenue in each of "our countries" in 2025, asked by EMEA's regional lead, with "role": "analyst" added to the arguments. The first server takes the role as an argument. The second is the semantic server, built for EMEA's lead with build_server("regional_lead", "EMEA"). The third is built by server_from_environment(), which reads SHOP_ROLE and SHOP_REGION as a host would set them when it launches the server for one person. Last, the example takes the policy out of the way on that third server, as the next paragraph explains.

role-bound-at-build.py Python 3.12.15 · anyio 4.16.0 · duckdb 2.0.0 · mcp 2.3.0 · pydantic 2.14.2 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""gq-017 from EMEA's regional lead, sent with role "analyst": a server that takes the role as an argument obeys it,and one that binds the role when it is built ignores it, and queries a database that holds only the lead's rows."""import anyiofrom mcp import Clientfrom mcp.server.mcpserver import MCPServerfrom lib.policies import PolicyLayer, PolicyToolbox, policy_forfrom lib.semantic import MetricRequest, loadfrom lib.semantic_server import build_server, server_from_environmentfrom lib.warehouse import connectcon, layer = connect(), load()naive = MCPServer("role-as-argument")@naive.tool()def query_metric(metrics: list[str], group_by: list[str] = [], filters: dict = {}, role: str = "analyst",                 region: str | None = None) -> list[list]:    """The anti-pattern: whoever calls the tool says who they are, and it queries the whole warehouse."""    toolbox = PolicyToolbox(con, PolicyLayer(layer, policy_for(role, region), con))    return [list(row) for row in toolbox.query_metric({"metrics": metrics, "group_by": group_by,                                                       "filters": filters}).answer.rows]request = {"metrics": ["net_revenue"], "group_by": ["order__country"], "filters": {"metric_time__year": 2025},           "role": "analyst"}                     # the lead's request, with a role the lead doesn't haveenvironment = {"SHOP_ROLE": "regional_lead", "SHOP_REGION": "EMEA"}   # what a host sets for a stdio serverasync def main():    servers = {"role as argument": naive, "role bound at build": build_server("regional_lead", "EMEA", con, layer),               "role from the environment": (stdio := server_from_environment(environment, con))}    for name, server in servers.items():        async with Client(server) as client:            result = (await client.call_tool("query_metric", request)).structured_content        rows = result["result"] if "result" in result else result["answer"]["rows"]        print(f"{name}, countries: {', '.join(row[0] for row in rows)}")        if "answer" in result:            provenance = result["answer"]["provenance"]            print(f"{name}, provenance: role {provenance['role']}, row filter {provenance['filters']['row filter']}")    # The policy bypassed: the plain semantic layer, which applies no role, on the stdio server's own database.    bypass = layer.query(stdio.database, MetricRequest.from_arguments({k: v for k, v in request.items() if k != "role"}))    print(f"role from the environment, no policy, countries: {', '.join(row[0] for row in bypass.rows)}")anyio.run(main)
Outputpython role-bound-at-build.py
role as argument, countries: AU, CA, DE, ES, FR, GB, JP, NL, SG, US
role bound at build, countries: DE, ES, FR, GB, NL
role bound at build, provenance: role regional_lead, row filter EMEA only
role from the environment, countries: DE, ES, FR, GB, NL
role from the environment, provenance: role regional_lead, row filter EMEA only
role from the environment, no policy, countries: DE, ES, FR, GB, NL

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

The server that takes the role as an argument returned AU, CA, DE, ES, FR, GB, JP, NL, SG, US: every country in the shop, because the request said "analyst" and the server believed it. The semantic server returned DE, ES, FR, GB, NL, EMEA's countries only, and its provenance says why: role regional_lead, row filter EMEA only. Its tools have no role parameter, so the extra argument went nowhere: the SDK validated the arguments against each tool's parameters, dropped the one it didn't know, and ran the call. The server built from the environment returned DE, ES, FR, GB, NL. Binding the role at build means one server per person, which is what stdio gives you: the host starts a process for its user, and the process lives as long as the host needs it (Choose stdio or Streamable HTTP).

The binding decides two things, and the database is the first. The book's primary access control is what the database allows each role: a read-only connection to that role's own views, which hold only its rows and columns, as chapter 7 built it, with chapter 9's policy rewrite as a second layer behind it. So build_server() doesn't take a connection to query. It takes the shop's warehouse, derives the role's policy, and opens the role's own database with agent_warehouse(shop, policy=policy, semantic=SEMANTIC_TABLES): chapter 7's database, plus the semantic layer's views and calendar as the role sees them, so that metrics can run on it. Every tool queries that database, and no caller can pair a role with another role's. The example's last step runs the lead's request through the plain semantic layer, which applies no role at all, straight on the database of the server built from the environment. It returned DE, ES, FR, GB, NL: with the policy bypassed, there are still only EMEA's rows to read. The server decides "forbidden" from the policy alone: a filter on region with any value but the lead's own is forbidden. Any other value the lead's rows don't hold, such as another region's country, gets a correctable error listing the lead's own values, whether it exists elsewhere or not, so it reveals nothing (chapter 9).

A server on HTTP serves many people, so it can't bind a role when it starts. It needs the role on each request, from the token. In the Python SDK, the server is given a TokenVerifier whose verify_token() returns an AccessToken with the client's ID, its scopes and the token's claims, and a tool reads the verified token with get_access_token() (mcp/server/auth/provider.py, mcp/server/auth/middleware/auth_context.py). The server would map a claim to a role and region there, and choose that role's database and policy for the call. The token must have been issued for this server: servers "MUST validate that access tokens were issued specifically for them" (authorization), which Model Context Protocol in Practice builds in bind each token to one server. Scopes don't replace the access rules. A scope can say that a token may call query_metric; only the database and the policy can say which rows it reads, and the specification's guidance lists "treating claimed scopes in token as sufficient without server-side authorization logic" as a common mistake (security best practices). The examples don't run this path: an in-memory client has no HTTP layer, so no bearer token reaches the server, and get_access_token() returns None there.

Working rule: Bind the role where the person was authenticated, never in a tool's arguments. For a stdio server, the host passes the role in the environment, and the server opens that role's read-only database and builds its policy once. For an HTTP server, take the role from a verified token issued for this server, on every request, and enforce the role's rows in the database and the policy whatever the token's scopes say. Leave role out of every input schema, so there's nothing for a model or a question to fill in, and log any argument a tool ignores, role first, because one is a sign that text the model read is steering it (treat ticket text as untrusted data).

Return answers with their provenance

With the role bound, the server's answers are right for the person asking. The host still has to know what each answer is: which metric produced it, with which filters, for which role and from data how fresh. Chapter 1 called that an answer's provenance, and the in-process agent keeps it in Answer.provenance. Over MCP, a tool can declare an outputSchema and return structuredContent that conforms to it, and a tool that does "SHOULD also return the serialized JSON in a TextContent block" (tools). Structured content is what lets a host's code, rather than its model, read an answer's role and filters. Model Context Protocol in Practice shows how the SDK builds the schema from a return type in return structured content with an output schema.

Every tool of the semantic server returns a ToolReply, a pydantic model with two fields: text, the text the in-process tool shows the model, and answer, the answer with its rows and provenance, or nothing when the result doesn't end the run. Failures take two routes. The specification reports a tool execution error as a result with isError set, with "actionable feedback that language models can use to self-correct" (tools). A request with a misspelt dimension is that kind of failure, so the server raises the SDK's ToolError with chapter 6's validation message. A request the role may not make isn't, and the server returns it as an answer that declines, with a reason that starts "forbidden", as PolicyToolbox does in process. The example sends three requests: the finance team's refund rate by region for fiscal 2026, the same with order_region for order__region, and the support team's net revenue for 2025.

tool-result-with-provenance.py Python 3.12.15 · anyio 4.16.0 · duckdb 2.0.0 · mcp 2.3.0 · pydantic 2.14.2 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""What a host receives from query_metric: an answer with its provenance as structured content, a request the modelcan correct as a tool error, and a request the role may not make as an answer that declines."""import jsonimport anyiofrom mcp import Clientfrom lib.semantic import loadfrom lib.semantic_server import build_serverfrom lib.warehouse import connectcon, layer = connect(), load()asks = {"finance, refund rate": ("finance", {"metrics": ["refund_rate"], "group_by": ["order__region"],                                             "filters": {"metric_time__fiscal_year": 2026}}),        "finance, misspelt dimension": ("finance", {"metrics": ["refund_rate"], "group_by": ["order_region"]}),        "support, net revenue": ("support", {"metrics": ["net_revenue"], "filters": {"metric_time__year": 2025}})}async def main():    for name, (role, request) in asks.items():        async with Client(build_server(role, shop=con, layer=layer)) as client:            tool = next(t for t in (await client.list_tools()).tools if t.name == "query_metric")            result = await client.call_tool("query_metric", request)        print(f"{name}, is_error: {result.is_error}")        if result.is_error:            print(f"{name}, text: {result.content[0].text}")            continue        answer = result.structured_content["answer"]        print(f"{name}, answered: {answer['answered']}")        print(f"{name}, text block is the same JSON: {json.loads(result.content[0].text) == result.structured_content}")        if answer["answered"]:            print(f"{name}, rows: {', '.join(f'{region} {rate:.2%}' for region, rate in answer['rows'])}")            print(f"{name}, provenance fields: {', '.join(answer['provenance'])}")            print(f"{name}, provenance: {json.dumps({k: v for k, v in answer['provenance'].items() if k != 'sql'})}")        else:            print(f"{name}, reason: {answer['reason']}")    print(f"output schema requires: {', '.join(tool.output_schema['required'])}")anyio.run(main)
Outputpython tool-result-with-provenance.py
finance, refund rate, is_error: False
finance, refund rate, answered: True
finance, refund rate, text block is the same JSON: True
finance, refund rate, rows: AMER 7.11%, APAC 6.02%, EMEA 8.45%
finance, refund rate, provenance fields: source, metric, sql, filters, role, data_as_of
finance, refund rate, provenance: {"source": "metric", "metric": "refund_rate", "filters": {"metric_time__fiscal_year": 2026}, "role": "finance", "data_as_of": "2026-06-27 02:00:00"}
finance, misspelt dimension, is_error: True
finance, misspelt dimension, text: Error executing tool query_metric: error: request rejected: refund_rate can't use dimension 'order_region'; did you mean order__region?
support, net revenue, is_error: False
support, net revenue, answered: False
support, net revenue, text block is the same JSON: True
support, net revenue, reason: forbidden: the support role may not see net_revenue
output schema requires: text, answer

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

Finance's refund rates came back as structured rows, AMER 7.11%, APAC 6.02%, EMEA 8.45%, computed on finance's own database, whose copy of the calendar gives each day its fiscal year. The provenance has these fields: source, metric, sql, filters, role, data_as_of. Without the SQL, the provenance reads {"source": "metric", "metric": "refund_rate", "filters": {"metric_time__fiscal_year": 2026}, "role": "finance", "data_as_of": "2026-06-27 02:00:00"}. The text block holds the same JSON (True), and the output schema requires text, answer.

The misspelt dimension came back with is_error set and the text "Error executing tool query_metric: error: request rejected: refund_rate can't use dimension 'order_region'; did you mean order__region?" The suggestion is chapter 6's, and the model can act on it. The words before it aren't the server's: the SDK's tool wrapper puts "Error executing tool" and the tool's name in front of the message of any ToolError a tool raises (mcp/server/mcpserver/tools/base.py). The in-process agent never showed the model that prefix, and Answer questions through a host measures what it changes.

Support's net revenue came back without is_error: answered False, with the reason "forbidden: the support role may not see net_revenue". We return a refusal as an answer, not an error, on purpose. An error invites the model to correct its request and try again, and there's no request support can correct into net revenue. A decline ends the run, as it did in chapter 9, and the host's code can see from the structured reason that the access rules, not the model, made the decision.

Working rule: Return a pydantic model from every tool, so that each answer travels as structured content with its rows, its role, its filters and its data's freshness, and a host's code can check them. Raise a tool error for a request the model can fix, with a message that says how. Return a refusal for access as an answer that declines, with its reason, so the host ends the run instead of letting the model try another way in.

Test the server in memory

The semantic server's structured answers make it testable by its fields, and the tests that matter are about access: a server built for one role must never answer as another. Tests like that should run on every change, so they can't need a port or a process. The Python SDK's Client connects to a server object in the same process when you pass it one instead of a URL, and raise_exceptions=True makes a failure outside a tool's body come back with its real message, as Model Context Protocol in Practice explains in test tools in memory. The same section lists what such tests can't reach: headers, origin checks and server-sent events, everything an HTTP server adds.

The example is the semantic server's test file. Each test builds a server for one role, calls one tool through an in-memory client, and asserts on the structured content. The tests cover the rules that chapter 9 measured, and the database under them: EMEA's lead gets the golden result for gq-017; EMEA's lead asking for APAC is declined as forbidden; the lead's server's own database, queried directly with no policy in the way, holds no other region's orders; support's catalog hides every metric that reads revenue, and only those; marketing's small groups are suppressed; and a misspelt dimension is a tool error that suggests the right one. The book pins no test runner, so the file runs its tests with a small helper in lib/semantic_server.py; under pytest, each would be an ordinary async test.

semantic-server-tests.py Python 3.12.15 · anyio 4.16.0 · duckdb 2.0.0 · mcp 2.3.0 · pydantic 2.14.2 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""In-memory tests of the semantic server, one role per server, and one check that a test can fail."""import anyiofrom lib.golden import golden_result, load_golden, results_matchfrom lib.semantic import loadfrom lib.semantic_server import build_server, call, run_testsfrom lib.warehouse import connectcon, layer = connect(), load()server = lambda role, region=None: build_server(role, region, con, layer)      # each on the role's own databasegold = golden_result(con, next(q for q in load_golden() if q.question_id == "gq-017"))lead_request = {"metrics": ["net_revenue"], "group_by": ["order__country"], "filters": {"metric_time__year": 2025}}HIDDEN_FROM_SUPPORT = {"average_order_value", "gross_revenue", "line_net_revenue", "net_revenue", "refund_rate"}async def test_lead_answer_matches_the_golden_result():    result = await call(server("regional_lead", "EMEA"), "query_metric", lead_request)    assert results_match(result.structured_content["answer"]["rows"], gold)async def test_lead_cannot_ask_for_another_region():    result = await call(server("regional_lead", "EMEA"), "query_metric", {"metrics": ["orders"],                        "filters": {"order__region": "APAC"}})    assert not result.is_error and result.structured_content["answer"]["reason"].startswith("forbidden")async def test_lead_database_holds_no_other_region():        # the policy layer bypassed: SQL straight to the database    regions = server("regional_lead", "EMEA").database.execute("SELECT DISTINCT region FROM analytics.fct_orders")    assert regions.fetchall() == [("EMEA",)]async def test_support_catalog_hides_every_revenue_metric():    listed = (await call(server("support"), "list_metrics", {})).structured_content["text"]    assert HIDDEN_FROM_SUPPORT == set(layer.metrics) - {line[2:].split(":")[0] for line in listed.splitlines()}async def test_marketing_small_groups_are_suppressed():    result = await call(server("marketing"), "query_metric", {"metrics": ["orders"], "group_by":                        ["order__channel", "order__country"], "filters": {"metric_time__month": "2026-06"}})    assert any("(under 10)" in row for row in result.structured_content["answer"]["rows"])async def test_unknown_dimension_is_a_tool_error_with_a_hint():    result = await call(server("finance"), "query_metric", {"metrics": ["orders"], "group_by": ["order_region"]})    assert result.is_error and "did you mean order__region?" in result.content[0].textasync def main():    passed = await run_tests(globals())    print(f"tests passed: {passed} of {passed}")    unbound = (await call(server("analyst"), "query_metric", lead_request)).structured_content["answer"]["rows"]    print(f"the lead's request on an analyst's server differs from the lead's gold: {not results_match(unbound, gold)}")anyio.run(main)
Outputpython semantic-server-tests.py
test_lead_answer_matches_the_golden_result: passed
test_lead_cannot_ask_for_another_region: passed
test_lead_database_holds_no_other_region: passed
test_support_catalog_hides_every_revenue_metric: passed
test_marketing_small_groups_are_suppressed: passed
test_unknown_dimension_is_a_tool_error_with_a_hint: passed
tests passed: 6 of 6
the lead's request on an analyst's server differs from the lead's gold: True

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

The run reports 6 of 6. A passing test is worth something only if it can fail, so the example also sends the first test's request to a server built for the analyst and compares the result with the lead's golden result. It differs (True), and it should: the analyst's server returns every country. So the first test would catch a server that lost its binding.

These tests check the server, not the transport. They go through the SDK's argument validation, its structured output and its error handling, which is where the error prefix comes from, but no request carries a token. For an HTTP deployment, add at least one test over HTTP that sends tokens for two roles and checks that each gets its own rows, and one with a token issued for another server, which must be refused.

Working rule: Test each role's rules through an in-memory client, with one server per role and assertions on the structured answer, and include a check that fails when the binding is wrong. Use the golden questions' results as the expected values, so the tests and the scores agree on what correct means. Add tests over HTTP for whatever an in-memory client can't send, tokens first.

Answer questions through a host

The tests show that the semantic server answers each tool call as the in-process agent would. The last question is whether an agent answers as well through it. A host is the application that runs the model and calls the server's tools for it, and the host here is the book's own: chapter 1's agent loop, run_agent() in lib/agent.py, with chapter 6's prompt and the recorded model, Qwen3-4B-Instruct-2507, that every chapter replays.

The only new piece is ServerToolbox in lib/semantic_server.py. It has the call() method the loop expects, and makes each call through an MCP client. ask_through_server() opens a client on a server, calls list_metrics to fill the prompt's catalog, and runs the loop in a worker thread, so that each tool call can reach the server on the event loop. A model's call that isn't a JSON object, or names a tool the server doesn't list, gets the in-process agent's message from the host without reaching the server, with the tools named in the in-process order. Model Context Protocol in Practice builds a general host in run the host loop; this one stays as thin as the book's loop.

Replay needs the model to see exactly what it saw when it was recorded, and over MCP it mostly does: the catalog and the prompt are the same, and so is every successful tool result's text. A tool error is the exception. Its text carries the SDK's prefix, so the model's next call has a prompt that no earlier recording holds. The recording mcp-host-runs, made with recordings/record_mcp_host.py, holds the replies to those calls only, from the same pinned model and settings. The example runs every golden question twice, in process as chapter 9 measured it and through a server built for the question's role, and compares the outcomes.

host-asks-the-server.py Python 3.12.15 · anyio 4.16.0 · duckdb 2.0.0 · mcp 2.3.0 · pydantic 2.14.2 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""The book's agent as an MCP host on every golden question, each tool on the asker's server, against chapter 9."""from collections import Counterimport anyiofrom lib.agent import run_agentfrom lib.golden import golden_result, grade, load_goldenfrom lib.metric_tools import catalogfrom lib.model import RecordedModelsfrom lib.policies import PolicyLayer, PolicyToolbox, policy_forfrom lib.prompts import metrics_first_promptfrom lib.semantic import loadfrom lib.semantic_server import ask_through_server, build_serverfrom lib.warehouse import connectcon, layer = connect(), load()model = RecordedModels("metric-agent-runs", "policy-agent-runs", "mcp-host-runs")   # ch 11 recorded the restasync def main():    counts, changed, details, errors = Counter(), [], [], 0    for q in load_golden():        seen_as = PolicyLayer(layer, policy_for(q.role, q.region or None), con)        local = run_agent(q.question, metrics_first_prompt(catalog(seen_as)), model, PolicyToolbox(con, seen_as), 4)        trace, calls = await ask_through_server(build_server(q.role, q.region or None, con, layer), q.question, model)        outcomes = [grade(q, t.answer, golden_result(con, q)) for t in (local, trace)]        counts[q.split, q.kind, "questions"] += 1        counts[q.split, q.kind, "correct"] += outcomes[1] == "correct"        errors += any("is_error" in c for c in calls)        if outcomes[0] != outcomes[1]:            changed.append(f"{q.question_id} ({q.split}, {q.kind}): {outcomes[0]} in process, "                           f"{outcomes[1]} over MCP, by {trace.steps[-2].tool}")            details += [f"{q.question_id} first request's filters: {trace.steps[0].arguments['filters']}",                        f"{q.question_id} asked: {trace.answer.reason.splitlines()[0]}"]        if q.question_id == "gq-017":            print(f"gq-017 calls: {', '.join(calls)}")    for split in ("dev", "test"):        for kind in ("answer", "decline", "forbidden"):            print(f"{split} {kind} correct over MCP: {counts[split, kind, 'correct']} of "                  f"{counts[split, kind, 'questions']}")    print(f"runs with a tool error: {errors}")    total = len(load_golden())    print(f"same outcome as in process: {total - len(changed)} of {total}")    print("\n".join(changed + details) or "no outcome changed")   # one line per changed question, then detailsanyio.run(main)
Outputpython host-asks-the-server.py
gq-017 calls: list_metrics, query_metric
dev answer correct over MCP: 11 of 17
dev decline correct over MCP: 3 of 4
dev forbidden correct over MCP: 3 of 3
test answer correct over MCP: 6 of 17
test decline correct over MCP: 4 of 4
test forbidden correct over MCP: 3 of 3
runs with a tool error: 11
same outcome as in process: 46 of 48
gq-013 (dev, answer): correct in process, declined over MCP, by ask_back
gq-014 (dev, answer): correct in process, declined over MCP, by ask_back
gq-013 first request's filters: {'order__order_date': '2026-05'}
gq-013 asked: Are you looking for orders in the entire month of May 2026, or specifically on a single day in May 2026?
gq-014 first request's filters: {'order__order_date': '2026-05'}
gq-014 asked: Do you mean orders placed in the entire month of May 2026 or on a specific day in May 2026?

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

For gq-017, the host made the calls list_metrics, query_metric: one to fill the prompt and one with the model's request. Over all the golden questions, the host scored 11 of 17 on the answer questions of the dev split, the golden questions used while building, against 13 of 17 in process. On the test split's, it scored 6 of 17, against 6 of 17 in process. Every forbidden question was declined, 3 of 3 on dev and 3 of 3 on test, because the server's policy hides what the role may not see and refuses what it may not ask, as in chapter 9. The outcome was the same as in process for 46 of 48 questions.

The two that changed both went through a tool error. 11 runs met one, and in two of them the outcome changed. For gq-013 and gq-014, orders in May 2026, the model's first request had the filters {'order__order_date': '2026-05'}, which validation rejects with a hint to filter metric_time__month instead. In process, the model followed the hint and got the right count. Over MCP, it read the same hint after "Error executing tool query_metric:", and asked the person back instead: "Are you looking for orders in the entire month of May 2026, or specifically on a single day in May 2026?" Both runs ended correct in process, declined over MCP, by ask_back. That's one sample per run at temperature 0.7, so it doesn't show that the prefix caused the change, only that a different text gave a different draw. It does show that an outcome can move with nothing changed but the transport's wording, and that only a score on the golden questions finds it.

Treat what the server returns as data in the host as well. OWASP's Top 10 for LLM applications lists an MCP server's output among the carriers of indirect prompt injection (OWASP, LLM01:2026), and a metric answer carries values from the warehouse, such as product names, that chapter 9 showed can hold instructions. The host shows the person the structured answer with its provenance, and never a model's retelling of it unchecked. Model Context Protocol in Practice covers the host's side in treat ticket text as untrusted data.

Working rule: Put the server under the host you already measured, and score it on the golden questions before anyone uses it, because the transport can change what the model reads. Compare outcomes question by question with the in-process runs, and read every run that changed. Record what the new text produces rather than editing the server's messages to match old recordings, and show people the structured answer with its provenance.

What goes wrong in production

These are the ways a semantic layer served over MCP fails once hosts connect to it, and how you'd notice each one.

One process serves several people. A stdio server bound to one role is safe only while one person's host owns it. Wrap it in a shared process, or put it behind HTTP without tokens, and everyone gets the role of whoever started it. The semantic server as built here writes no audit log, so a deployment should record chapter 9's entry for each answer, with the principal the server was built for. You'd notice there: one principal whose requests come from several users or hosts, or at a rate no one person asks at. Serve more than one person only over HTTP, with the role from each request's token.

The token's role and the policy file disagree. A claim that names a role roles.toml doesn't have, or a regional lead with no region, must fail closed. policy_for() raises PolicyError for both, and a server that catches it and falls back to a default role has undone the binding. Test the mapping from claims to roles with tokens for every role and for none.

The server's connection outranks its role. The policy narrows queries, but the database decides what a query can reach. A server that binds the lead's role and then queries with the analyst's connection is one bug away from a leak. Open the role's own read-only database when you bind the role, as build_server() does, and check in a test that the connection can't read another region's rows, as the tests do.

A host caches an old catalog. At revision 2026-07-28, list results can carry a lifetime and a cache scope, so hosts may reuse them (Cache list results). list_metrics is a tool call, so the protocol doesn't cache its catalog, but a host that caches it anyway shows a metric after the policy removed it. The access rules still refuse the request, so the cost is a forbidden decline where the model would have declined on its own.

An SDK upgrade changes what the model reads. The prefix on tool errors comes from the SDK, not from the server's code, and so does the text of argument validation errors. A new release can reword either, and the agent's behaviour after an error changes with it. Pin the SDK, rerun the golden questions through the host on every upgrade, and read the runs whose outcome changed.

Two servers describe the same metrics. Vendors serve semantic layers over MCP too. dbt's MCP server offers Semantic Layer tools named list_metrics, get_dimensions and query_metrics, among others, for projects on the dbt platform (dbt Labs), and Snowflake's managed MCP server answers questions over semantic views through Cortex Analyst, using the connecting user's default role and a separate grant for each tool (Snowflake). Those are vendors' descriptions of their own products. If a host connects both a vendor's server and yours, its model sees two list_metrics tools with two sets of definitions. Give each host one server per semantic layer, and name tools apart when it must have two, as Model Context Protocol in Practice does in name tools across servers.

A client on an older revision connects. mcp 2.3.0 also serves clients on the 2025 revisions through the old handshake, and dbt's server pins the SDK's 1.x line (dbt-mcp). The semantic server's tools don't depend on the revision, but an HTTP deployment's sessions and cancellation do; Model Context Protocol in Practice covers that in serve old and new clients through the window.

Working rule: Run one stdio server per person, or HTTP with the role from each request's token, and fail closed on any claim the policy file doesn't know. Query with the role's own read-only database, not a shared one. Pin the SDK and rerun the golden questions through the host whenever it changes. Keep one server per semantic layer in each host, record an audit entry for every answer, and read it for a principal that more than one person seems to share.

Decisions this chapter made

This is where the book ends. You started from an agent that wrote SQL over a schema and got most golden questions wrong. You can now build the version the shop can use: one that asks governed metrics first, writes SQL only on its role's read-only database, inside guards, declines when it can't answer, keeps each role inside what it may see, and serves all of that to any host over MCP. Above all, you can measure each change on golden questions written from your own work, so the next change you make is one you can prove.

  • The semantic server offers chapter 6's five tools, each a thin call into chapter 9's PolicyToolbox, so the server and the in-process agent run the same code.
  • One tool list for every role, in name order and marked read-only; the role changes what list_metrics and the other tools return.
  • The role is bound when the server is built: from the environment for stdio, and from a verified token on each request for HTTP. No tool takes a role argument.
  • Every server queries its role's own read-only database, chapter 7's database with the semantic layer's views and calendar added; the policy is the second layer.
  • Every tool returns structured content: the text the model is shown, and the answer with its rows and provenance.
  • A request the model can fix is a tool error; a request the role may not make is an answer that declines, with a reason that starts "forbidden".
  • The server is tested in memory, one role per server, against the golden results, with a check that fails when the binding is wrong.
  • The HTTP path is described, not run: the examples open no socket, and an in-memory client carries no token, so the mapping from a verified token to a role needs its own tests over HTTP.
  • The host is the book's own loop and prompt. Calls the earlier recordings hold are replayed; mcp-host-runs holds only the calls that follow an error text the SDK changed.

This chapter, in this edition

Edition 2026.11.3Examples passed Nov 6, 20265 of 5 examples in this chapter pass

Anyone can check this book or correct it. Found something wrong? Report it with the section's ID, the last part of the link beside each heading, or fix it yourself with a pull request.

Cite this section

Format

Every format names this edition. Its link opens the current edition, where a section’s heading says when its text last changed. Accessed and retrieved dates are the day you open this.

On this page

Chapter 11 of 11: Serve the semantic layer over MCP

  1. Turn metrics into tools
  2. Carry the caller's role into every query
  3. Return answers with their provenance
  4. Test the server in memory
  5. Answer questions through a host
  6. What goes wrong in production
  7. Decisions this chapter made

Back to the topAll chapters