Governed MCP server
The tools Ward exposes over the Model Context Protocol, the scopes that gate them, and the record each call leaves behind.
What this server is
Ward exposes its semantic layer to MCP clients as a set of tools. A client connects, discovers the tools its identity is scoped to, and calls them. Every call resolves through the same semantic layer and the same policy plane the console uses, and lands in the same audit log.
The boundary is worth stating before anything else. This server does not hand an agent a warehouse connection. There is no tool that returns a credential, and no tool that widens its own scope. An agent reaches the metrics and tables on its list and nothing else, and that list is bounded by a warehouse grant Ward does not control.
Three things are true of every call, and the rest of this page is the detail behind them:
- Read-only is the default. A new agent identity can resolve metrics and nothing else.
- Permission is evaluated when the query runs, against the agent identity, below the model. An instruction in a prompt cannot widen it.
- Every call is logged with the question, the metric it resolved, the SQL it ran and the objects it touched, whether it succeeded or was refused.
In the console: Policies
Tools manifest
Ten tools. Seven are on for a read-only identity, three are off until granted. A client's discovery response contains only the tools that identity holds, so a tool it cannot call is a tool it cannot see.
| Tool | What it returns | Scope required | Default |
|---|---|---|---|
| list_metrics | Metric keys the caller is scoped to resolve, with one-line descriptions. | metrics:read | On |
| describe_metric | Formula, grain, additivity, valid dimensions and the owner of one metric. | metrics:read | On |
| list_dimensions | Dimensions and levels valid against a given metric or entity. | metrics:read | On |
| query_metric | Rows for one metric at a stated grain, plus the SQL that produced them and the definition version it resolved through. | metrics:query | On |
| list_sources | Connected sources and schemas, filtered to objects the caller is granted. | catalog:read | On |
| describe_table | Columns, types and masking state for one granted table. | catalog:read | On |
| get_lineage | The source columns behind a metric, back to the system of record. | catalog:read | On |
| list_insights | Insight cards in the tenant the caller is scoped to see. | insights:read | On |
| run_sql | Rows for a read-only statement against granted tables. Skips metric resolution. | sql:read | Off |
| write_back | Writes one row set to a granted target system. Requires a named approver. | write:{target} | Off |
run_sql is off by default and should usually stay off. It is the one tool that lets an agent reach a table without a metric definition in front of it, which means it is the one tool that can return a number nobody has agreed the meaning of. Grant it to a human analyst's identity if you want an escape hatch. Granting it to a scheduled agent gives up most of what the semantic layer was for.
The wire format is ordinary MCP. Two tool definitions, as a client receives them:
{
"tools": [
{
"name": "query_metric",
"description": "Resolve a metric through the Ward semantic layer at a stated grain. Returns rows, the SQL that produced them, and the definition version used.",
"inputSchema": {
"type": "object",
"required": ["metric", "grain"],
"properties": {
"metric": { "type": "string", "description": "Metric key, as returned by list_metrics." },
"grain": { "type": "array", "items": { "type": "string" },
"description": "Dimensions to group by. Must be valid for this metric." },
"filters": { "type": "object", "description": "Dimension filters. Applied in addition to policy row filters, never instead of them." },
"start_date": { "type": "string", "format": "date" },
"end_date": { "type": "string", "format": "date" },
"limit": { "type": "integer", "maximum": 10000, "default": 1000 }
}
}
},
{
"name": "run_sql",
"description": "Run a read-only statement against granted tables. Off unless the sql:read scope is granted.",
"inputSchema": {
"type": "object",
"required": ["sql"],
"properties": {
"sql": { "type": "string", "description": "A single SELECT or WITH statement. DDL and DML are rejected before planning." },
"limit": { "type": "integer", "maximum": 10000, "default": 1000 }
}
}
}
]
}
query_metric returns the SQL it ran alongside the rows. That is not a debugging convenience. It is the reason an answer from an agent can be checked by the person who has to defend the number in a meeting, and it is the field the audit log stores verbatim.
In the console: Entities
Authentication and identity
An MCP client authenticates as an agent identity, not as a person. The identity is created in Policies, the console issues a credential for it, and the client presents that credential on every call.
The distinction matters more than it looks. An agent is not a seat. It runs on a schedule, against tables no human opens, often on behalf of whoever triggered it. Binding its permissions to the person who created it would mean a junior analyst's agent inherits a director's access the day that analyst changes role.
- The agent identity is the subject policy is evaluated against. It has its own scope, its own owner and its own expiry.
- Where a call was triggered by a person, the client passes that person forward and both identities are recorded. The person's access narrows the call and can never widen it.
- Credentials carry an expiry. An identity with no owner, or an owner who has left, stops working rather than continuing quietly.
- Revoking an identity in Policies takes effect on the next call. There is no cached grant.
Ward reaches your warehouse on a service account you issue, which is a separate credential from the one the MCP client holds. The next section is about how the two combine, and it is the part a security reviewer should read first.
In the console: Policies
The permission model
Three layers, evaluated in order. A call has to pass all three. Any one of them can refuse it, and the outermost layer is one Ward does not administer.
| Layer | Enforced by | What it bounds | Who changes it |
|---|---|---|---|
| 1. The warehouse grant | Your data platform | Every object Ward can ever read, for any caller, in any tenant. | Your DBA, in your warehouse. |
| 2. The agent scope | Ward | Which tools, metrics and tables this one identity may reach. | A policy owner, in the console. |
| 3. Row and column policy | Ward, at query time | Which rows come back and which columns are masked for this caller. | A policy owner, in the console. |
Layer 1 is the answer to the question a security review actually asks. If you revoke the Snowflake role, every agent in the tenant loses that data on its next call, regardless of what any Ward policy says. Ward cannot grant itself access it was not given, and there is no mode in which it holds an admin role on your warehouse.
Layer 2 is where least privilege is expressed. A new agent identity is created with metrics:read and metrics:query and an empty table list. It can discover that a metric exists and it cannot resolve one until a metric is added to its scope. Grants are per object. There is no wildcard on a first grant, because a wildcard written on the day a schema has nine tables is a grant on the forty it has next year.
Layer 3 runs on every query, for every caller, human or agent, and it is evaluated in the query plan rather than applied to the result. A filter applied after the rows come back is not a security control, it is a display preference.
The three compose by intersection, never by union. An agent scoped to a metric it has no underlying table grant for cannot resolve it. A widening in layer 2 does not reach past layer 1.
In the console: Policies
A worked example
One agent that watches replenishment for two regions. It should be able to resolve three metrics, see no customer identifiers, and write nothing. Here is every grant that produces that, starting on the warehouse.
Layer 1, in Snowflake. This is the credential Ward connects on and it is the ceiling on everything below it. Run it as a role that can grant, and keep the script in your own change management, because this is the artifact your auditor will ask to see.
-- A role that can read three tables and nothing else.
CREATE ROLE IF NOT EXISTS ward_reader;
-- Compute. A dedicated warehouse makes Ward's spend separable on your bill
-- and lets you cap it with a resource monitor.
GRANT USAGE ON WAREHOUSE wh_ward TO ROLE ward_reader;
-- Traversal. USAGE is not SELECT: it lets the role see that the schema
-- exists, which is required before any table grant below can resolve.
GRANT USAGE ON DATABASE retail TO ROLE ward_reader;
GRANT USAGE ON SCHEMA retail.sales TO ROLE ward_reader;
-- The objects, named one at a time.
GRANT SELECT ON TABLE retail.sales.fct_pos_line TO ROLE ward_reader;
GRANT SELECT ON TABLE retail.sales.dim_item TO ROLE ward_reader;
GRANT SELECT ON TABLE retail.sales.dim_store TO ROLE ward_reader;
-- Deliberately NOT granted:
-- GRANT SELECT ON ALL TABLES IN SCHEMA retail.sales ...
-- GRANT SELECT ON FUTURE TABLES IN SCHEMA retail.sales ...
-- Both are the same mistake at different times. The first grants whatever
-- happens to be in the schema today, including the table someone restored
-- from a production dump last week. The second grants everything anyone
-- creates there for the rest of the system's life, which means the scope
-- you reviewed is not the scope you are running six months from now.
CREATE USER IF NOT EXISTS ward_svc
DEFAULT_ROLE = ward_reader
DEFAULT_WAREHOUSE = wh_ward
TYPE = SERVICE;
GRANT ROLE ward_reader TO USER ward_svc;
Still layer 1: the row and column rules you want enforced by the platform itself rather than taken on trust from a vendor. Ward evaluates its own policy on top of these, but anything expressed here holds even if Ward is wrong.
-- Two regions, enforced in the query plan rather than in a filter the
-- caller could omit.
CREATE ROW ACCESS POLICY retail.sales.rap_ward_region
AS (region VARCHAR) RETURNS BOOLEAN ->
CURRENT_ROLE() <> 'WARD_READER'
OR region IN ('NORTH', 'CENTRAL');
ALTER TABLE retail.sales.fct_pos_line
ADD ROW ACCESS POLICY retail.sales.rap_ward_region ON (region);
-- The loyalty identifier is the only column on this table that identifies a
-- person. Hashed for every role that is not explicitly cleared for it, which
-- includes ward_reader.
CREATE MASKING POLICY retail.sales.mask_loyalty_id
AS (val VARCHAR) RETURNS VARCHAR ->
CASE WHEN CURRENT_ROLE() IN ('ANALYST_PII') THEN val
ELSE SHA2(val, 256)
END;
ALTER TABLE retail.sales.fct_pos_line
MODIFY COLUMN loyalty_id SET MASKING POLICY retail.sales.mask_loyalty_id;
Layers 2 and 3, in the console. These are records in the tenant, reviewed as a diff and approved by a named person before they bind. They are not files in your repository and Ward does not ask you to keep them there. What the approved scope contains:
| Grant | Value |
|---|---|
| Agent identity | agent:replenishment-watch |
| Owner | A named person. The identity expires if the owner leaves. |
| Warehouse credential | ward_reader on Snowflake, the role created above |
| Tools | list_metrics, describe_metric, list_dimensions, query_metric, get_lineage |
| Metrics | fill_rate, on_shelf_availability, waste_rate |
| Tables reachable | retail.sales.fct_pos_line, retail.sales.dim_item, retail.sales.dim_store |
| Row scope | region in (NORTH, CENTRAL) |
| Columns masked | loyalty_id |
| Write targets | None. write_back is not in the tool list, so the agent cannot see it. |
| Approver required | Not applicable. Nothing in this scope writes. |
What that produces when the agent asks for fill rate by store for last week: query_metric is in the tool list, fill_rate is in the metric scope, the three tables behind it are granted, the row policy narrows the scan to two regions in the query plan, and loyalty_id never leaves the warehouse unhashed. The rows come back with the SQL attached.
What happens when the same agent asks for margin by customer. The metric is not in scope, so it is refused at layer 2 before any SQL is planned:
{
"isError": true,
"content": [
{
"type": "text",
"text": "Denied: metric 'gross_margin' is not in scope for agent:replenishment-watch. Scoped metrics: fill_rate, on_shelf_availability, waste_rate. Request the grant from the scope owner; it cannot be widened from this session."
}
],
"_meta": {
"ward/decision": "deny",
"ward/denied_at": "agent_scope",
"ward/policy_id": "pol_7f3a91",
"ward/request_id": "req_01JX8S4K2M",
"ward/audit_event": "evt_01JX8S4K2N"
}
}
The refusal names the scope and the way to change it, and it says the session cannot change it. That wording is deliberate. A model told only "denied" will retry with a rephrased question, and the useful behaviour is for it to stop and report what it could not reach.
A denial is a logged event with its own audit id, exactly like a success. The attempts that were refused are usually more interesting to a reviewer than the ones that were not.
In the console: Policies
Write access
write_back is off for every identity until it is granted per target, and a grant names an approver role rather than a person. An agent that can only read is a reporting tool. An agent that can act is a change to your operating process, and it is governed like one.
- Grants are per target system and per action. A grant to write a purchase order quantity is not a grant to write a price.
- Each write names an approver role. The write is staged and does not land until someone holding that role approves it.
- The audit record carries the before state, the after state, the agent, the approver and the time between the two.
- A write that fails a policy check is logged with the rows it would have touched.
Ward does not offer a mode where an agent writes without a named approver. That is a product boundary rather than a configuration default, and it is the answer to the question most security reviews reach in their second hour.
In the console: Policies
The audit record
Every call writes one event. The event answers who asked, what resolved, what ran, what it touched and what came back, and it covers refusals as well as successes.
The fields, in the order a reviewer reads them:
- Who asked. The agent identity, the person it acted on behalf of where there was one, the client, and how it authenticated.
- What resolved. The metric key, the definition version, the grain and the filters. This is the field that distinguishes a governed call from a raw query.
- What ran. The SQL, verbatim, plus a hash of it so identical statements group.
- What it touched. The tables and columns read, the rows returned, the bytes scanned, and for a write, the rows written and the target.
- What was decided. Allow, deny or partial, the policy that decided it, the row filters applied and the columns masked.
The full field-by-field schema, copy-pasteable DDL for landing the log in your own warehouse, worked sample entries and a SOC 2 control mapping are on the audit log reference. That page is the one to send to a reviewer who asks what you will be able to show them.
Reference: what a complete AI agent audit log looks like, with the schema. Audit log schema →
In the console: Policies
Related reference
Four pages that answer the questions this one tends to raise next.
The record every call writes, field by field, with DDL you can paste into your own warehouse and a SOC 2 mapping. The audit log schema →
How to check any of this rather than take it from a documentation page. A vendor-neutral acceptance test with a stopwatch and a rubric. The 48-hour test protocol →
dbt Labs ships an official MCP server too. What it is scoped by, and what the dbt Cloud requirement costs. Ward and the dbt Semantic Layer →
Cortex Analyst inherits Snowflake RBAC, which is the strongest thing about it. Where that leaves you, and one default worth checking today. Ward and Snowflake Cortex Analyst →
What a metric definition has to declare before an agent resolves it, worked through five grocery metrics. Grocery metric definitions →
In the console
The screens this guide walks through, captured from a running tenant.