Snowflake-managed MCP or the Cortex Agents REST API: code

Every file in the Snowflake-managed MCP or the Cortex Agents REST API recipe. To run the recipe, download mcp-or-agent-rest-api.zip, unzip it, and run the scripts from the mcp-or-agent-rest-api/ folder as the recipe describes. Comments in the files that say “see README” refer to the recipe page; the zip includes it as README.md.

client/

client/mcp_stream_check.py

#!/usr/bin/env python3
"""
Stream diagnostic for a Snowflake-managed MCP server.

Sends one tools/call the way a spec-compliant client does and times every frame
of the Server-Sent Events stream as it arrives. It reports:

  transport          Content-Type, plus the no-buffering headers the server sets
  keep-alives        count of `: keep-alive` comment frames and their interval
  longest gap        the longest silence on the wire, from the request onward,
                     against your proxy's idle timeout
  progress           notifications/progress events received for the progressToken it
                     sends (--no-progress-token sends none, for comparison)
  result             result events, and whether the stream ended with `data: [DONE]`
  request id         x-snowflake-request-id, which joins CORTEX_AGENT_USAGE_HISTORY

Use it to find out why a client times out. If the longest gap is under the idle
timeout of every proxy between the client and Snowflake, keep-alives are doing
their job and a timeout comes from the client's own per-request limit instead.
If progress events arrive, a client that resets its timeout on progress (the MCP
specification allows it) can wait longer than that limit.

Standard library only.

    export SNOWFLAKE_ACCOUNT_URL=https://<account_identifier>.snowflakecomputing.com
    export SNOWFLAKE_TOKEN=<OAuth access token or programmatic access token>
    python client/mcp_stream_check.py
    python client/mcp_stream_check.py --idle-timeout 30 --compare-json
    python client/mcp_stream_check.py --tool revenue_agent --text "Total revenue by region?"

Use hyphens, not underscores, in the account hostname.
"""

import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request

DATABASE = os.environ.get("MCP_DATABASE", "COOKBOOK_MCP_OR_AGENT_REST_API")
SCHEMA = os.environ.get("MCP_SCHEMA", "APP")
SERVER = os.environ.get("MCP_SERVER", "REVENUE_MCP")

SSE_ACCEPT = "application/json, text/event-stream"
PROGRESS_TOKEN = "stream-check-1"


def _url(account_url):
    return f"{account_url.rstrip('/')}/api/v2/databases/{DATABASE}/schemas/{SCHEMA}/mcp-servers/{SERVER}"


def _request(account_url, token, payload, accept):
    return urllib.request.Request(
        _url(account_url),
        data=json.dumps(payload).encode(),
        method="POST",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": accept,
        },
    )


def _open(request, timeout):
    try:
        return urllib.request.urlopen(request, timeout=timeout)
    except urllib.error.HTTPError as err:
        detail = err.read().decode(errors="replace")[:500]
        sys.exit(f"HTTP {err.code}: {detail}")


def list_tools(account_url, token, timeout):
    """tools/list answers with one JSON body whatever the Accept header says."""
    payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
    with _open(_request(account_url, token, payload, SSE_ACCEPT), timeout) as response:
        content_type = response.headers.get("Content-Type", "")
        raw = response.read().decode()
    if "text/event-stream" in content_type:
        raw = next((ln[5:].strip() for ln in raw.splitlines() if ln.startswith("data:")), "{}")
    tools = (json.loads(raw).get("result") or {}).get("tools") or []
    return tools, content_type


def _text_argument(tool):
    """Name of the argument that carries the question: `text` for agent tools,
    `message` for Cortex Analyst, `query` for Cortex Search."""
    schema = tool.get("inputSchema") or {}
    for name in schema.get("required") or []:
        if name in ("text", "message", "query"):
            return name
    for name in ("text", "message", "query"):
        if name in (schema.get("properties") or {}):
            return name
    return "text"


def stream_call(account_url, token, tool, arguments, accept, timeout, progress_token=True):
    """One tools/call. Reads the body line by line and timestamps every frame."""
    params = {"name": tool, "arguments": arguments}
    if progress_token:
        params["_meta"] = {"progressToken": PROGRESS_TOKEN}
    payload = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": params}
    report = {
        "accept": accept,
        "progress_token": progress_token,
        "keepalives": [],
        "progress": [],
        "results": 0,
        "done": False,
        "other": [],
        "error": None,
        "result_bytes": 0,
    }
    started = time.monotonic()
    frame_times = []
    with _open(_request(account_url, token, payload, accept), timeout) as response:
        report["headers_s"] = time.monotonic() - started
        report["status"] = response.status
        for name in ("Content-Type", "Cache-Control", "X-Accel-Buffering", "x-snowflake-request-id"):
            report[name] = response.headers.get(name, "")
        streaming = "text/event-stream" in report["Content-Type"]
        if not streaming:
            body = response.read()
            report["first_byte_s"] = time.monotonic() - started
            frame_times.append(report["first_byte_s"])
            report["result_bytes"] = len(body)
            report["results"] = 1
            rpc = json.loads(body or b"{}")
            report["error"] = rpc.get("error")
        else:
            while True:
                line = response.readline()
                if not line:
                    break  # stream closed
                at = time.monotonic() - started
                text = line.decode(errors="replace").rstrip("\r\n")
                if not text:
                    continue  # event separator
                report.setdefault("first_byte_s", at)
                frame_times.append(at)
                if text.startswith(":"):
                    report["keepalives"].append(at)
                elif text.startswith("data:"):
                    _classify(text[5:].strip(), at, report)
                elif not text.startswith(("event:", "id:", "retry:")):
                    report["other"].append(text[:80])
    report["total_s"] = time.monotonic() - started
    report["streaming"] = streaming
    # Measure from the request, not from the headers: a proxy's idle clock starts
    # when the request is sent, and the server sends headers with the first frame.
    edges = [0.0, *frame_times, report["total_s"]]
    report["longest_gap_s"] = max(b - a for a, b in zip(edges, edges[1:]))
    return report


def _classify(data, at, report):
    if data == "[DONE]":
        report["done"] = True
        return
    try:
        message = json.loads(data)
    except json.JSONDecodeError:
        report["other"].append(data[:80])
        return
    if message.get("method") == "notifications/progress":
        report["progress"].append((at, (message.get("params") or {}).get("progress")))
    elif "result" in message or "error" in message:
        report["results"] += 1
        report["result_bytes"] += len(data)
        report["error"] = report["error"] or message.get("error")
    else:
        report["other"].append(data[:80])


def _interval(times):
    if len(times) < 2:
        return "n/a"
    gaps = [b - a for a, b in zip(times, times[1:])]
    return f"every {sum(gaps) / len(gaps):.1f}s (min {min(gaps):.1f}s, max {max(gaps):.1f}s)"


def print_report(report, idle_timeout):
    print(f"\n  Accept             {report['accept']}")
    print(f"  progressToken      {'sent' if report['progress_token'] else 'not sent'}")
    print(f"  HTTP status        {report['status']}")
    print(f"  Content-Type       {report['Content-Type'] or '(none)'}")
    print(f"  Cache-Control      {report['Cache-Control'] or '(none)'}")
    print(f"  X-Accel-Buffering  {report['X-Accel-Buffering'] or '(none)'}")
    print(f"  request id         {report['x-snowflake-request-id'] or '(none)'}")
    print(f"  headers after      {report['headers_s']:6.1f}s")
    print(f"  first byte after   {report.get('first_byte_s', report['total_s']):6.1f}s")
    if report["streaming"]:
        print(f"  keep-alives        {len(report['keepalives'])}, {_interval(report['keepalives'])}")
        print(f"  progress events    {len(report['progress'])}, {_interval([t for t, _ in report['progress']])}")
        print(f"  result events      {report['results']} ({report['result_bytes'] / 1024:.0f} KB)")
        print(f"  ended with [DONE]  {'yes' if report['done'] else 'no, the stream closed'}")
        if report["other"]:
            print(f"  other frames       {len(report['other'])}, first: {report['other'][0]}")
    else:
        print(f"  body               one JSON document ({report['result_bytes'] / 1024:.0f} KB)")
    print(f"  total              {report['total_s']:6.1f}s")
    flag = "  OVER the idle timeout" if report["longest_gap_s"] >= idle_timeout else ""
    print(f"  longest silence    {report['longest_gap_s']:6.1f}s  (idle timeout {idle_timeout:.0f}s){flag}")
    if report["error"]:
        print(f"  JSON-RPC error     {report['error']}")


def verdict(report, idle_timeout, client_deadline):
    """Plain-language reading of one streamed call."""
    lines = []
    if not report["streaming"]:
        lines.append(
            "The server sent one JSON body. Managed MCP streams tools/call by default, so the "
            "Accept header that reached Snowflake did not list text/event-stream. Check the "
            "client and any proxy that rewrites headers."
        )
    elif report["longest_gap_s"] < idle_timeout:
        lines.append(
            f"Keep-alives kept every silence under {idle_timeout:.0f}s, so a proxy with that idle "
            "timeout will not drop this call."
        )
    else:
        lines.append(
            f"A {report['longest_gap_s']:.0f}s silence exceeds the {idle_timeout:.0f}s idle timeout. "
            "Look for a proxy that buffers the response (it would hide keep-alives)."
        )
    if report["total_s"] >= client_deadline:
        lines.append(
            f"The call took {report['total_s']:.0f}s, past the {client_deadline:.0f}s client limit. "
            "Streaming does not deliver the answer earlier. Reset the timeout on progress if your "
            "client supports it, make the agent faster, or split the work into submit and fetch "
            "calls (see the long-running agents over MCP recipe)."
        )
    if report["streaming"] and report["progress_token"]:
        if report["progress"]:
            lines.append(
                f"{len(report['progress'])} progress notifications arrived. A client that resets its "
                "per-request timeout on progress keeps waiting while the tool runs; one that does not "
                "still times out at its own limit."
            )
        else:
            lines.append(
                "No progress notifications arrived for the progressToken. Rely on keep-alives "
                "for idle timeouts and on your client's per-request limit for everything else."
            )
    if report["streaming"] and not report["done"]:
        lines.append("The stream ended on close, without data: [DONE]. End reading on close, not on the sentinel.")
    if report["results"] != 1:
        lines.append(f"Expected one result event, got {report['results']}.")
    return lines


def main():
    parser = argparse.ArgumentParser(description="Time the SSE stream of one managed MCP tools/call.")
    parser.add_argument("--tool", help="tool name; defaults to the first tool the server lists")
    parser.add_argument("--text", default="What was total revenue by region in August 2026?")
    parser.add_argument(
        "--idle-timeout",
        type=float,
        default=float(os.environ.get("MCP_IDLE_TIMEOUT_S", "60")),
        help="shortest idle timeout of any proxy or load balancer on the path (seconds)",
    )
    parser.add_argument(
        "--client-deadline",
        type=float,
        default=float(os.environ.get("MCP_CLIENT_CEILING_S", "120")),
        help="your MCP client's absolute per-call limit (seconds)",
    )
    parser.add_argument(
        "--no-progress-token",
        action="store_true",
        help="omit _meta.progressToken, as a client that does not track progress would",
    )
    parser.add_argument(
        "--compare-json",
        action="store_true",
        help="repeat the call with Accept: application/json to show what a non-streaming client sees",
    )
    parser.add_argument("--timeout", type=float, default=600, help="socket read timeout (seconds)")
    args = parser.parse_args()

    account_url = os.environ.get("SNOWFLAKE_ACCOUNT_URL")
    token = os.environ.get("SNOWFLAKE_TOKEN")
    if not account_url or not token:
        sys.exit("Set SNOWFLAKE_ACCOUNT_URL and SNOWFLAKE_TOKEN.")

    tools, list_type = list_tools(account_url, token, args.timeout)
    if not tools:
        sys.exit("tools/list returned no tools: check USAGE on the server for the session's role.")
    tool = next((t for t in tools if t.get("name") == args.tool), None) if args.tool else tools[0]
    if tool is None:
        sys.exit(f"no tool named {args.tool}; the server lists {[t.get('name') for t in tools]}")
    print(f"\nserver   {DATABASE}.{SCHEMA}.{SERVER}")
    print(f"tools    {[t.get('name') for t in tools]}  (tools/list Content-Type: {list_type})")
    print(f"calling  {tool['name']}")

    arguments = {_text_argument(tool): args.text}
    progress_token = not args.no_progress_token
    report = stream_call(account_url, token, tool["name"], arguments, SSE_ACCEPT, args.timeout, progress_token)
    print_report(report, args.idle_timeout)
    if args.compare_json:
        print("\nsame call, Accept: application/json only")
        json_report = stream_call(
            account_url, token, tool["name"], arguments, "application/json", args.timeout, progress_token
        )
        print_report(json_report, args.idle_timeout)

    print("\nverdict")
    for line in verdict(report, args.idle_timeout, args.client_deadline):
        print(f"  - {line}")
    healthy = (
        report["streaming"]
        and report["results"] == 1
        and report["longest_gap_s"] < args.idle_timeout
        and report["total_s"] < args.client_deadline
    )
    sys.exit(0 if healthy else 1)


if __name__ == "__main__":
    main()

sql/

sql/01_setup.sql

-- =============================================================================
-- 01_setup.sql
-- A small governed data product for an external MCP client to reach:
-- roles, warehouse, database, synthetic data, a fail-closed row access policy,
-- a semantic view, and one Cortex Agent with a Cortex Analyst tool.
--
--   *_ADMIN   owns the recipe objects.
--   *_ACCESS  is the MCP access role: the role an MCP user's session runs as.
--             It is entitled to the EMEA region only, which is how Verify shows
--             that row access policies apply through the agent and MCP path.
--
-- ACCOUNTADMIN is used only to create and grant the roles, warehouse, and
-- database. Idempotent: safe to rerun.
-- =============================================================================

SET cookbook_db          = 'COOKBOOK_MCP_OR_AGENT_REST_API';
SET cookbook_schema      = 'COOKBOOK_MCP_OR_AGENT_REST_API.APP';
SET cookbook_wh          = 'COOKBOOK_MCP_OR_AGENT_REST_API_WH';
SET cookbook_admin_role  = 'COOKBOOK_MCP_OR_AGENT_REST_API_ADMIN';
SET cookbook_access_role = 'COOKBOOK_MCP_OR_AGENT_REST_API_ACCESS';
SET me                   = '"' || CURRENT_USER() || '"';

-- ── Account-scoped setup (ACCOUNTADMIN) ─────────────────────────────────────
USE ROLE ACCOUNTADMIN;

CREATE ROLE IF NOT EXISTS IDENTIFIER($cookbook_admin_role);
CREATE ROLE IF NOT EXISTS IDENTIFIER($cookbook_access_role);
GRANT ROLE IDENTIFIER($cookbook_admin_role)  TO USER IDENTIFIER($me);
GRANT ROLE IDENTIFIER($cookbook_access_role) TO USER IDENTIFIER($me);

CREATE WAREHOUSE IF NOT EXISTS IDENTIFIER($cookbook_wh)
  WAREHOUSE_SIZE = XSMALL AUTO_SUSPEND = 60 AUTO_RESUME = TRUE INITIALLY_SUSPENDED = TRUE;
CREATE DATABASE IF NOT EXISTS IDENTIFIER($cookbook_db);

GRANT USAGE ON WAREHOUSE IDENTIFIER($cookbook_wh) TO ROLE IDENTIFIER($cookbook_admin_role);
GRANT USAGE, CREATE SCHEMA ON DATABASE IDENTIFIER($cookbook_db) TO ROLE IDENTIFIER($cookbook_admin_role);
GRANT DATABASE ROLE SNOWFLAKE.CORTEX_USER TO ROLE IDENTIFIER($cookbook_admin_role);

-- ── Recipe objects (admin role) ─────────────────────────────────────────────
USE ROLE IDENTIFIER($cookbook_admin_role);
USE WAREHOUSE IDENTIFIER($cookbook_wh);
CREATE SCHEMA IF NOT EXISTS IDENTIFIER($cookbook_schema);
USE SCHEMA IDENTIFIER($cookbook_schema);

-- Which role may see which region. Onboarding a population is a row, not DDL.
CREATE OR REPLACE TABLE REGION_ENTITLEMENTS (ROLE_NAME STRING, REGION STRING);
INSERT INTO REGION_ENTITLEMENTS VALUES
  ('COOKBOOK_MCP_OR_AGENT_REST_API_ACCESS', 'EMEA');

-- Synthetic monthly revenue for a fictional company, Northwind.
CREATE OR REPLACE TABLE ORDERS (
  ORDER_MONTH  DATE          COMMENT 'First day of the order month',
  REGION       STRING        COMMENT 'Sales region: AMER, EMEA, or APJ',
  PRODUCT_LINE STRING        COMMENT 'Product line: Hardware, Software, or Services',
  REVENUE_USD  NUMBER(14,2)  COMMENT 'Recognized revenue in US dollars'
);
INSERT INTO ORDERS VALUES
  ('2026-07-01', 'AMER', 'Hardware', 1200000), ('2026-07-01', 'AMER', 'Software', 2100000), ('2026-07-01', 'AMER', 'Services', 600000),
  ('2026-07-01', 'EMEA', 'Hardware',  800000), ('2026-07-01', 'EMEA', 'Software', 1300000), ('2026-07-01', 'EMEA', 'Services', 400000),
  ('2026-07-01', 'APJ',  'Hardware',  500000), ('2026-07-01', 'APJ',  'Software',  700000), ('2026-07-01', 'APJ',  'Services', 200000),
  ('2026-08-01', 'AMER', 'Hardware', 1300000), ('2026-08-01', 'AMER', 'Software', 2200000), ('2026-08-01', 'AMER', 'Services', 650000),
  ('2026-08-01', 'EMEA', 'Hardware',  850000), ('2026-08-01', 'EMEA', 'Software', 1400000), ('2026-08-01', 'EMEA', 'Services', 450000),
  ('2026-08-01', 'APJ',  'Hardware',  550000), ('2026-08-01', 'APJ',  'Software',  750000), ('2026-08-01', 'APJ',  'Services', 250000);

-- Recreating ORDERS above detached the policy, so it can be replaced here.
-- Fail closed: a role with no matching row sees nothing. The admin role sees all
-- rows so it can build and test the semantic view.
CREATE OR REPLACE ROW ACCESS POLICY REGION_RAP AS (REGION_VALUE STRING) RETURNS BOOLEAN ->
  IS_ROLE_IN_SESSION('COOKBOOK_MCP_OR_AGENT_REST_API_ADMIN')
  OR EXISTS (
    SELECT 1 FROM REGION_ENTITLEMENTS e
    WHERE e.ROLE_NAME = CURRENT_ROLE() AND e.REGION = REGION_VALUE
  );

ALTER TABLE ORDERS ADD ROW ACCESS POLICY REGION_RAP ON (REGION);

-- CREATE OR ALTER keeps grants on the semantic view across reruns.
CREATE OR ALTER SEMANTIC VIEW ORDERS_SV
  TABLES (
    orders AS ORDERS PRIMARY KEY (ORDER_MONTH, REGION, PRODUCT_LINE)
      COMMENT = 'Monthly recognized revenue by region and product line'
  )
  FACTS (
    orders.revenue AS REVENUE_USD COMMENT = 'Recognized revenue in US dollars'
  )
  DIMENSIONS (
    orders.order_month  AS ORDER_MONTH  COMMENT = 'First day of the order month',
    orders.region       AS REGION       COMMENT = 'Sales region: AMER, EMEA, or APJ',
    orders.product_line AS PRODUCT_LINE COMMENT = 'Product line: Hardware, Software, or Services'
  )
  METRICS (
    orders.total_revenue AS SUM(orders.revenue) COMMENT = 'Total recognized revenue in US dollars'
  )
  COMMENT = 'Northwind revenue, synthetic data for the managed MCP best-practices recipe';

-- One agent, one Cortex Analyst tool. Add Cortex Search as a second phase.
-- The spec is YAML inside $$ ... $$ and cannot read SQL variables: if you change
-- the names above, change them here too. COMMENT must come before COPY GRANTS.
CREATE OR REPLACE AGENT REVENUE_AGENT
  COMMENT = 'Governed revenue questions over ORDERS_SV'
  COPY GRANTS
  FROM SPECIFICATION $$
models:
  orchestration: auto
instructions:
  orchestration: "Use RevenueAnalyst for every question about revenue, regions, product lines, or months."
  response: "Lead with the number. Format dollars in millions. State the month and region."
tools:
  - tool_spec:
      type: "cortex_analyst_text_to_sql"
      name: "RevenueAnalyst"
      description: "Answers questions about Northwind recognized revenue by month, region, and product line."
tool_resources:
  RevenueAnalyst:
    semantic_view: "COOKBOOK_MCP_OR_AGENT_REST_API.APP.ORDERS_SV"
    execution_environment:
      type: "warehouse"
      warehouse: "COOKBOOK_MCP_OR_AGENT_REST_API_WH"
      query_timeout: 120
$$;

SELECT 'setup complete' AS status;

sql/02_mcp_server.sql

-- =============================================================================
-- 02_mcp_server.sql
-- The MCP server, exposing the agent as one CORTEX_AGENT_RUN tool, and the
-- least-privilege grants for the MCP access role.
--
-- There is no ALTER for an MCP server's specification, so changes go through
-- CREATE OR REPLACE, which drops every grant on the server. The server grant is
-- therefore made in this same script, after the CREATE. Always rerun the whole file.
--
-- USAGE on the server lets the role discover tools (tools/list). Invoking the
-- agent tool also needs USAGE on the agent and privileges on everything the
-- agent's tools read.
-- =============================================================================

SET cookbook_db          = 'COOKBOOK_MCP_OR_AGENT_REST_API';
SET cookbook_schema      = 'COOKBOOK_MCP_OR_AGENT_REST_API.APP';
SET cookbook_wh          = 'COOKBOOK_MCP_OR_AGENT_REST_API_WH';
SET cookbook_admin_role  = 'COOKBOOK_MCP_OR_AGENT_REST_API_ADMIN';
SET cookbook_access_role = 'COOKBOOK_MCP_OR_AGENT_REST_API_ACCESS';

USE ROLE IDENTIFIER($cookbook_admin_role);
USE WAREHOUSE IDENTIFIER($cookbook_wh);
USE SCHEMA IDENTIFIER($cookbook_schema);

-- The client picks a tool from its name and description, so write them to be
-- domain-specific and mutually exclusive with any other tool on the server.
CREATE OR REPLACE MCP SERVER REVENUE_MCP
  FROM SPECIFICATION $$
tools:
  - title: "Northwind revenue agent"
    name: "revenue_agent"
    type: "CORTEX_AGENT_RUN"
    identifier: "COOKBOOK_MCP_OR_AGENT_REST_API.APP.REVENUE_AGENT"
    description: "Governed questions about Northwind recognized revenue by month, region, and product line."
$$;

-- ── Grants for the MCP access role ──────────────────────────────────────────
-- Account-scoped grants (warehouse, database role) need the owner of those
-- objects; run this block as ACCOUNTADMIN or a role that owns them.
USE ROLE ACCOUNTADMIN;
GRANT DATABASE ROLE SNOWFLAKE.CORTEX_AGENT_USER TO ROLE IDENTIFIER($cookbook_access_role);
GRANT USAGE ON WAREHOUSE IDENTIFIER($cookbook_wh) TO ROLE IDENTIFIER($cookbook_access_role);
GRANT USAGE ON DATABASE IDENTIFIER($cookbook_db) TO ROLE IDENTIFIER($cookbook_access_role);

USE ROLE IDENTIFIER($cookbook_admin_role);
GRANT USAGE ON SCHEMA IDENTIFIER($cookbook_schema) TO ROLE IDENTIFIER($cookbook_access_role);
-- Reapplied on every run because CREATE OR REPLACE MCP SERVER dropped it.
GRANT USAGE ON MCP SERVER REVENUE_MCP TO ROLE IDENTIFIER($cookbook_access_role);
GRANT USAGE ON AGENT REVENUE_AGENT TO ROLE IDENTIFIER($cookbook_access_role);
-- A semantic view needs SELECT only. The agent's Analyst tool can also query the
-- base table under the caller's role, so grant SELECT on it too. The row access
-- policy still filters every row the caller reads.
GRANT SELECT ON SEMANTIC VIEW ORDERS_SV TO ROLE IDENTIFIER($cookbook_access_role);
GRANT SELECT ON TABLE ORDERS TO ROLE IDENTIFIER($cookbook_access_role);

SHOW GRANTS ON MCP SERVER COOKBOOK_MCP_OR_AGENT_REST_API.APP.REVENUE_MCP;

sql/03_oauth_integration.sql

-- =============================================================================
-- 03_oauth_integration.sql   (ACCOUNT-LEVEL: review before running)
--
-- Snowflake OAuth is the default for managed MCP servers. This creates one
-- confidential OAuth client that every user of the MCP client shares; each user
-- still signs in individually and gets their own token.
--
-- Before running:
--   1. Replace OAUTH_REDIRECT_URI with the exact callback URI your MCP client
--      shows during connector setup. A mismatch fails the consent step.
--   2. Decide which of your users will connect. Their session runs as their
--      DEFAULT_ROLE, so it must be the access role, with a DEFAULT_WAREHOUSE
--      (step 3 below). This script changes no user; step 3 is a template you
--      apply to your own users, and teardown does not undo it.
--
-- To authenticate against your own identity provider instead, create an
-- External OAuth integration and bind it with OAUTH_AUTHORIZATION_SERVER at the
-- account, database, or schema level. See the MCP server docs.
-- =============================================================================

SET cookbook_oauth_integration = 'COOKBOOK_MCP_OR_AGENT_REST_API_OAUTH';

USE ROLE ACCOUNTADMIN;

-- Recommended MCP settings: no secondary roles, and only the MCP access role
-- may be the session role.
CREATE SECURITY INTEGRATION IF NOT EXISTS COOKBOOK_MCP_OR_AGENT_REST_API_OAUTH
  TYPE = OAUTH
  OAUTH_CLIENT = CUSTOM
  ENABLED = TRUE
  OAUTH_CLIENT_TYPE = 'CONFIDENTIAL'
  OAUTH_REDIRECT_URI = 'https://mcp-client.example.com/oauth/callback'
  OAUTH_USE_SECONDARY_ROLES = NONE
  ALLOWED_ROLES_LIST = ('COOKBOOK_MCP_OR_AGENT_REST_API_ACCESS');

-- Client ID and secret to paste into the MCP client. The integration name must
-- be uppercase here.
SELECT SYSTEM$SHOW_OAUTH_CLIENT_SECRETS($cookbook_oauth_integration);

-- 3. Template, not run by this script. For each of your users who will connect
--    (replace <user>), first record the current values, because teardown drops
--    the role and warehouse set below and restores nothing:
--   DESCRIBE USER <user>;   -- note DEFAULT_ROLE and DEFAULT_WAREHOUSE
--   GRANT ROLE COOKBOOK_MCP_OR_AGENT_REST_API_ACCESS TO USER <user>;
--   ALTER USER <user> SET
--     DEFAULT_ROLE      = 'COOKBOOK_MCP_OR_AGENT_REST_API_ACCESS'
--     DEFAULT_WAREHOUSE = 'COOKBOOK_MCP_OR_AGENT_REST_API_WH';
-- Before teardown, put back the values you recorded:
--   ALTER USER <user> SET DEFAULT_ROLE = '<previous role>' DEFAULT_WAREHOUSE = '<previous warehouse>';

sql/99_teardown.sql

-- =============================================================================
-- 99_teardown.sql
-- Removes everything this recipe created. Safe to run twice.
--
-- The security integration is account-level. No script in this recipe changes an
-- existing user's DEFAULT_ROLE or DEFAULT_WAREHOUSE, so this script restores
-- none. If you applied the template in 03_oauth_integration.sql to your own
-- users, restore the values you recorded before running this, because the role
-- and warehouse those settings point at are dropped below.
-- =============================================================================

SET cookbook_db          = 'COOKBOOK_MCP_OR_AGENT_REST_API';
SET cookbook_wh          = 'COOKBOOK_MCP_OR_AGENT_REST_API_WH';
SET cookbook_admin_role  = 'COOKBOOK_MCP_OR_AGENT_REST_API_ADMIN';
SET cookbook_access_role = 'COOKBOOK_MCP_OR_AGENT_REST_API_ACCESS';

USE ROLE ACCOUNTADMIN;

-- For each of your users you changed with the 03 template (you run this, not teardown):
--   ALTER USER <user> SET DEFAULT_ROLE = '<previous role>' DEFAULT_WAREHOUSE = '<previous warehouse>';

DROP SECURITY INTEGRATION IF EXISTS COOKBOOK_MCP_OR_AGENT_REST_API_OAUTH;

-- The database drop removes the MCP server, agent, semantic view, tables, and policy.
DROP DATABASE  IF EXISTS IDENTIFIER($cookbook_db);
DROP WAREHOUSE IF EXISTS IDENTIFIER($cookbook_wh);
DROP ROLE      IF EXISTS IDENTIFIER($cookbook_access_role);
DROP ROLE      IF EXISTS IDENTIFIER($cookbook_admin_role);

SHOW DATABASES LIKE 'COOKBOOK_MCP_OR_AGENT_REST_API%';