Skip to content

Running Read-Only Queries

This recipe runs SQL through the query_database tool of a pgEdge Postgres MCP Server: it runs a SELECT and prints the rows, and then tries an INSERT to show that the server refuses to change the data. The server runs every query in a read-only transaction, so PostgreSQL itself rejects a write, whatever the SQL looks like. Each step shows the code in every supported language; selecting a language tab switches every tab on the site to that language.

These examples are for pgEdge Postgres MCP Server 1.1.0.

The program prints the following output:

Columns: name, price
Row: Acme Seedling Kit, 19.95
Row: Acme Quiet Keys, 64.25
Row: Acme Trail Runner, 89.99
INSERT rejected: Error iterating rows: ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)

Preparing the Ingredients

The examples need a running pgEdge Postgres MCP Server in HTTP mode with authentication turned on, and an API token that it accepts. The scripts/start_postgres_mcp.sh script in the cookbook repository starts a test server in Docker. The script creates a cookbook_mcp schema in the cookbook test PostgreSQL server (which scripts/start_test_server.sh starts) holding a products table of made-up products, with an embedding of each product's description made by Ollama with the nomic-embed-text model. It then generates a token, starts the server, and prints the MCP_URL and MCP_TOKEN values that the examples read from the environment.

The server speaks the Model Context Protocol (MCP), which is built on JSON-RPC 2.0. A client sends each request as a POST to /mcp/v1 with the token in an Authorization: Bearer header, and the server replies with a JSON object that holds either a result or an error. The server replies with HTTP 200 to a JSON-RPC error as well as to a success, so each program checks the reply for an error member as well as the HTTP status. No program prints the token.

The following table lists the HTTP client that each example uses:

Language HTTP client
Python httpx
TypeScript The fetch function that Node.js provides
Java The java.net.http package, with Jackson for JSON
C# The HttpClient class and System.Text.Json, which .NET provides
Go The net/http package
Rust reqwest, with serde_json for JSON
Ruby Net::HTTP and json from the standard library
curl curl and jq

The following tabs show the dependency declaration from each example project; the TypeScript project needs only the compiler and the Node.js type definitions, the Java project adds Jackson, the Rust project adds reqwest and serde_json, and the C#, Go and Ruby projects need nothing beyond their standard libraries:

# httpx, an HTTP client with a requests-like API.
httpx>=0.28,<1
{
  "name": "read-only",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/main.js"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.9.0"
  }
}
<dependencies>
  <!-- Jackson, for reading and writing JSON; the HTTP client is part of
       the JDK. -->
  <dependency>
    <groupId>tools.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>3.2.4</version>
  </dependency>
</dependencies>
<!-- HttpClient and System.Text.Json are part of .NET, so the project
     needs no packages. -->
// The program uses only the standard library, so there is nothing to add.
[dependencies]
# The blocking client, with TLS from rustls and request and response bodies
# as JSON; the default features add HTTP/2 and other things this example
# does not use.
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
serde_json = "1"
source "https://rubygems.org"

# The program uses only Net::HTTP and json from the standard library, so
# there is no gem to add.

Importing the Libraries

Each program begins by importing the HTTP client, along with any standard library modules that it uses. The TypeScript program imports nothing, because Node.js provides fetch as a global, and the curl script checks that curl and jq are installed. The following tabs show the imports for each language:

import os
import sys

import httpx
// Node.js provides fetch and AbortSignal as globals, so this program
// imports nothing.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
)
use std::env;
use std::error::Error;
use std::process::ExitCode;
use std::time::Duration;

use reqwest::blocking::Client;
use serde_json::{json, Value};
require "json"
require "net/http"
require "uri"
# Stop at the first failed command or unset variable, and treat a failure
# anywhere in a pipeline as a failure of the whole pipeline.
set -euo pipefail

# The script needs curl to make the requests and jq to build the requests
# and read the replies.
for tool in curl jq; do
    command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; }
done

Configuring the Program

The examples read their settings from the following environment variables, and treat an empty value as unset; each program allows 30 seconds for a reply:

Variable Default Description
MCP_URL http://localhost:8080 The base URL of the MCP Server.
MCP_TOKEN None; it must be set An API token for the server.

A program calls a tool with the tools/call method, giving the tool's name and its arguments, and the server needs no initialize handshake first. A tool's reply is text, in the text member of the first item of the result's content array. A tool that fails still returns a result rather than a JSON-RPC error, with isError set to true and the error message as its text, so the helper returns that flag with the text.

The following tabs show how each program reads its settings, and how it calls a tool:

# The or operator treats an empty variable as unset.
BASE_URL = (os.environ.get("MCP_URL") or "http://localhost:8080").rstrip("/")
TOKEN = os.environ.get("MCP_TOKEN") or ""

TIMEOUT_SECONDS = 30


class McpError(Exception):
    """Raised when the MCP Server rejects a request or replies oddly."""


def call_tool(client: httpx.Client, name: str,
              arguments: dict) -> tuple[str, bool]:
    """Calls a tool with tools/call and returns the text of its reply and
    whether the tool failed, which it reports by setting isError.

    The server answers a JSON-RPC error with HTTP status 200, so the
    function checks the reply for an error member as well as the HTTP
    status.
    """
    response = client.post(
        "/mcp/v1",
        headers={"Authorization": f"Bearer {TOKEN}"},
        json={"jsonrpc": "2.0", "id": 1, "method": "tools/call",
              "params": {"name": name, "arguments": arguments}},
    )
    if response.status_code != 200:
        raise McpError(f"{name} failed with HTTP {response.status_code}: "
                       f"{response.text[:200]}")
    try:
        reply = response.json()
    except ValueError:
        reply = None
    if not isinstance(reply, dict) or not ("result" in reply or "error" in reply):
        raise McpError(f"{name} failed: unexpected response: "
                       f"{response.text[:200]}")
    if "error" in reply:
        raise McpError(f"{name} failed: {reply['error'].get('message')}")
    result = reply["result"]
    return result["content"][0]["text"], bool(result.get("isError"))
// The || operator, unlike ??, also treats an empty variable as unset.
const baseUrl = (process.env.MCP_URL || "http://localhost:8080").replace(/\/+$/, "");
const token = process.env.MCP_TOKEN || "";

const timeoutMs = 30_000;

// Calls a tool with tools/call and returns the text of its reply and
// whether the tool failed, which it reports by setting isError. The server
// answers a JSON-RPC error with HTTP status 200, so the function checks the
// reply for an error member as well as the HTTP status.
async function callTool(
  name: string,
  args: object,
): Promise<{ text: string; failed: boolean }> {
  const response = await fetch(`${baseUrl}/mcp/v1`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name, arguments: args },
    }),
    signal: AbortSignal.timeout(timeoutMs),
  });
  const body = await response.text();
  if (response.status !== 200) {
    throw new Error(`${name} failed with HTTP ${response.status}: ${body.slice(0, 200)}`);
  }
  let reply: any;
  try {
    reply = JSON.parse(body);
  } catch {
    reply = undefined;
  }
  if (typeof reply !== "object" || reply === null || !("result" in reply || "error" in reply)) {
    throw new Error(`${name} failed: unexpected response: ${body.slice(0, 200)}`);
  }
  if ("error" in reply) {
    throw new Error(`${name} failed: ${reply.error?.message}`);
  }
  const text = reply.result?.content?.[0]?.text;
  if (typeof text !== "string") {
    throw new Error(`${name} failed: unexpected result: ${body.slice(0, 200)}`);
  }
  return { text, failed: reply.result.isError === true };
}
/** Returns the value of an environment variable, or a fallback. */
static String envOr(String name, String fallback) {
    String value = System.getenv(name);
    return (value != null && !value.isEmpty()) ? value : fallback;
}

static final String BASE_URL =
        envOr("MCP_URL", "http://localhost:8080").replaceAll("/+$", "");
static final String TOKEN = envOr("MCP_TOKEN", "");

static final Duration TIMEOUT = Duration.ofSeconds(30);

/** Thrown when the MCP Server rejects a request or replies oddly. */
static class McpException extends Exception {
    McpException(String message) {
        super(message);
    }
}

/** The text of a tool's reply, and whether the tool failed. */
record ToolResult(String text, boolean failed) {
}

// A client and a mapper are safe to share, so the program creates
// only one of each.
static final HttpClient CLIENT = HttpClient.newBuilder()
        .connectTimeout(TIMEOUT)
        .build();
static final ObjectMapper MAPPER = JsonMapper.builder().build();

/** Returns at most the first 200 characters of a reply. */
static String shorten(String text) {
    return text.length() > 200 ? text.substring(0, 200) : text;
}

/**
 * Sends a request and returns the response with its whole body. The
 * request timeout stops applying once the headers arrive, so a server
 * that stalls part of the way through the body would hang send(); this
 * applies the timeout to the whole exchange instead.
 */
static HttpResponse<String> send(HttpRequest request)
        throws IOException, InterruptedException {
    CompletableFuture<HttpResponse<String>> pending =
            CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString());
    try {
        return pending.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
    } catch (TimeoutException e) {
        pending.cancel(true);
        throw new HttpTimeoutException("request timed out");
    } catch (ExecutionException e) {
        if (e.getCause() instanceof IOException cause) {
            throw cause;
        }
        throw new IOException(e.getCause());
    }
}

/** Parses a reply as a JSON object, or returns null if it is not one. */
static JsonNode parseObject(String text) {
    try {
        JsonNode node = MAPPER.readTree(text);
        return node.isObject() ? node : null;
    } catch (JacksonException e) {
        return null;
    }
}

/**
 * Calls a tool with tools/call and returns the text of its reply and
 * whether the tool failed, which it reports by setting isError. The
 * server answers a JSON-RPC error with HTTP status 200, so the method
 * checks the reply for an error member as well as the HTTP status.
 */
static ToolResult callTool(String name, Map<String, Object> arguments)
        throws IOException, InterruptedException, McpException {
    String message = MAPPER.writeValueAsString(Map.of(
            "jsonrpc", "2.0", "id", 1, "method", "tools/call",
            "params", Map.of("name", name, "arguments", arguments)));
    HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/mcp/v1"))
            .timeout(TIMEOUT)
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(message))
            .build();
    HttpResponse<String> response = send(request);
    if (response.statusCode() != 200) {
        throw new McpException(name + " failed with HTTP "
                + response.statusCode() + ": " + shorten(response.body()));
    }
    JsonNode reply = parseObject(response.body());
    if (reply == null || !(reply.has("result") || reply.has("error"))) {
        throw new McpException(name + " failed: unexpected response: "
                + shorten(response.body()));
    }
    if (reply.has("error")) {
        throw new McpException(name + " failed: "
                + reply.path("error").path("message").asString());
    }
    JsonNode result = reply.path("result");
    JsonNode text = result.path("content").path(0).path("text");
    if (!text.isString()) {
        throw new McpException(name + " failed: unexpected result: "
                + shorten(response.body()));
    }
    return new ToolResult(text.stringValue(), result.path("isError").asBoolean(false));
}
// Returns the value of an environment variable, or a fallback if the
// variable is unset or empty.
static string EnvOr(string name, string fallback) =>
    Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
        ? value
        : fallback;

static string BaseUrl() => EnvOr("MCP_URL", "http://localhost:8080").TrimEnd('/');
static string Token() => EnvOr("MCP_TOKEN", "");

const int TimeoutSeconds = 30;

// Returns at most the first 200 characters of a reply.
static string Shorten(string text) =>
    text.Length > 200 ? text[..200] : text;

// Parses a reply as a JSON object, or returns null if it is not one.
static JsonObject? ParseObject(string text)
{
    try
    {
        return JsonNode.Parse(text) as JsonObject;
    }
    catch (JsonException)
    {
        return null;
    }
}

// Calls a tool with tools/call and returns the text of its reply and
// whether the tool failed, which it reports by setting isError. The server
// answers a JSON-RPC error with HTTP status 200, so the function checks the
// reply for an error member as well as the HTTP status.
static async Task<(string Text, bool Failed)> CallToolAsync(
    HttpClient client, string name, JsonObject arguments)
{
    var message = new JsonObject
    {
        ["jsonrpc"] = "2.0",
        ["id"] = 1,
        ["method"] = "tools/call",
        ["params"] = new JsonObject { ["name"] = name, ["arguments"] = arguments },
    };
    using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl() + "/mcp/v1")
    {
        Content = new StringContent(
            message.ToJsonString(), Encoding.UTF8, "application/json"),
    };
    request.Headers.Add("Authorization", $"Bearer {Token()}");
    using var response = await client.SendAsync(request);
    var body = await response.Content.ReadAsStringAsync();
    if (response.StatusCode != HttpStatusCode.OK)
    {
        throw new McpException(
            $"{name} failed with HTTP {(int)response.StatusCode}: {Shorten(body)}");
    }
    var reply = ParseObject(body);
    if (reply is null || !(reply.ContainsKey("result") || reply.ContainsKey("error")))
    {
        throw new McpException($"{name} failed: unexpected response: {Shorten(body)}");
    }
    if (reply.ContainsKey("error"))
    {
        throw new McpException($"{name} failed: {reply["error"]?["message"]}");
    }
    var result = reply["result"];
    if (result?["content"]?[0]?["text"] is not JsonValue textValue ||
        !textValue.TryGetValue(out string? text))
    {
        throw new McpException($"{name} failed: unexpected result: {Shorten(body)}");
    }
    var failed = result["isError"] is JsonValue isError &&
        isError.TryGetValue(out bool flag) && flag;
    return (text, failed);
}
// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

var (
    baseURL = strings.TrimRight(envOr("MCP_URL", "http://localhost:8080"), "/")
    token   = os.Getenv("MCP_TOKEN")
)

const timeout = 30 * time.Second

// shorten returns at most the first 200 bytes of a reply.
func shorten(body []byte) string {
    if len(body) > 200 {
        body = body[:200]
    }
    return string(body)
}

// callTool calls a tool with tools/call and returns the text of its reply
// and whether the tool failed, which it reports by setting isError. The
// server answers a JSON-RPC error with HTTP status 200, so the function
// checks the reply for an error member as well as the HTTP status.
func callTool(client *http.Client, name string, arguments map[string]any) (string, bool, error) {
    payload, err := json.Marshal(map[string]any{
        "jsonrpc": "2.0", "id": 1, "method": "tools/call",
        "params": map[string]any{"name": name, "arguments": arguments},
    })
    if err != nil {
        return "", false, err
    }
    request, err := http.NewRequest(http.MethodPost, baseURL+"/mcp/v1", bytes.NewReader(payload))
    if err != nil {
        return "", false, err
    }
    request.Header.Set("Authorization", "Bearer "+token)
    request.Header.Set("Content-Type", "application/json")

    response, err := client.Do(request)
    if err != nil {
        return "", false, err
    }
    defer response.Body.Close()
    body, err := io.ReadAll(response.Body)
    if err != nil {
        return "", false, err
    }
    if response.StatusCode != http.StatusOK {
        return "", false, fmt.Errorf("%s failed with HTTP %d: %s", name, response.StatusCode, shorten(body))
    }
    var reply struct {
        Result *struct {
            Content []struct {
                Text string `json:"text"`
            } `json:"content"`
            IsError bool `json:"isError"`
        } `json:"result"`
        Error *struct {
            Message string `json:"message"`
        } `json:"error"`
    }
    if err := json.Unmarshal(body, &reply); err != nil || (reply.Result == nil && reply.Error == nil) {
        return "", false, fmt.Errorf("%s failed: unexpected response: %s", name, shorten(body))
    }
    if reply.Error != nil {
        return "", false, fmt.Errorf("%s failed: %s", name, reply.Error.Message)
    }
    if len(reply.Result.Content) == 0 {
        return "", false, fmt.Errorf("%s failed: unexpected result: %s", name, shorten(body))
    }
    return reply.Result.Content[0].Text, reply.Result.IsError, nil
}
/// Returns the value of an environment variable, or a fallback if the
/// variable is unset or empty.
fn env_or(name: &str, fallback: &str) -> String {
    env::var(name)
        .ok()
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| fallback.to_string())
}

fn base_url() -> String {
    env_or("MCP_URL", "http://localhost:8080")
        .trim_end_matches('/')
        .to_string()
}

fn token() -> String {
    env_or("MCP_TOKEN", "")
}

const TIMEOUT: Duration = Duration::from_secs(30);

/// The errors that the functions below return; a string converts into one
/// with the ? operator or into().
type BoxError = Box<dyn Error>;

/// Returns at most the first 200 characters of a reply.
fn shorten(text: &str) -> String {
    text.chars().take(200).collect()
}

/// Calls a tool with tools/call and returns the text of its reply and
/// whether the tool failed, which it reports by setting isError. The
/// server answers a JSON-RPC error with HTTP status 200, so the function
/// checks the reply for an error member as well as the HTTP status.
fn call_tool(client: &Client, name: &str, arguments: Value) -> Result<(String, bool), BoxError> {
    let message = json!({
        "jsonrpc": "2.0", "id": 1, "method": "tools/call",
        "params": {"name": name, "arguments": arguments},
    });
    let response = client
        .post(format!("{}/mcp/v1", base_url()))
        .bearer_auth(token())
        .json(&message)
        .send()?;
    let status = response.status().as_u16();
    let body = response.text()?;
    if status != 200 {
        return Err(format!("{name} failed with HTTP {status}: {}", shorten(&body)).into());
    }
    let reply = match serde_json::from_str::<Value>(&body) {
        Ok(reply) if reply.get("result").is_some() || reply.get("error").is_some() => reply,
        _ => return Err(format!("{name} failed: unexpected response: {}", shorten(&body)).into()),
    };
    if let Some(error) = reply.get("error") {
        let message = error["message"].as_str().unwrap_or("unknown error");
        return Err(format!("{name} failed: {message}").into());
    }
    let result = &reply["result"];
    let Some(text) = result["content"][0]["text"].as_str() else {
        return Err(format!("{name} failed: unexpected result: {}", shorten(&body)).into());
    };
    Ok((text.to_string(), result["isError"].as_bool() == Some(true)))
}
# Returns the value of an environment variable, or a fallback if the
# variable is unset or empty.
def env_or(name, fallback)
  value = ENV[name]
  value.nil? || value.empty? ? fallback : value
end

BASE_URL = env_or("MCP_URL", "http://localhost:8080").sub(%r{/+\z}, "")
TOKEN = env_or("MCP_TOKEN", "")

TIMEOUT_SECONDS = 30

# Raised when the MCP Server rejects a request or replies oddly.
class McpError < StandardError; end

# Parses a reply as a JSON object, or returns nil if it is not one.
def parse_object(text)
  body = JSON.parse(text.to_s)
  body.is_a?(Hash) ? body : nil
rescue JSON::ParserError
  nil
end

# Calls a tool with tools/call and returns the text of its reply and
# whether the tool failed, which it reports by setting isError. The server
# answers a JSON-RPC error with HTTP status 200, so the method checks the
# reply for an error member as well as the HTTP status.
def call_tool(http, name, arguments)
  request = Net::HTTP::Post.new(URI("#{BASE_URL}/mcp/v1"))
  request["Authorization"] = "Bearer #{TOKEN}"
  request["Content-Type"] = "application/json"
  request.body = JSON.generate({ jsonrpc: "2.0", id: 1, method: "tools/call",
                                 params: { name: name, arguments: arguments } })
  response = http.request(request)
  unless response.code == "200"
    raise McpError, "#{name} failed with HTTP #{response.code}: #{response.body.to_s[0, 200]}"
  end

  reply = parse_object(response.body)
  unless reply && (reply.key?("result") || reply.key?("error"))
    raise McpError, "#{name} failed: unexpected response: #{response.body.to_s[0, 200]}"
  end
  raise McpError, "#{name} failed: #{reply["error"]["message"]}" if reply.key?("error")

  result = reply["result"]
  text = result.is_a?(Hash) ? result.dig("content", 0, "text") : nil
  raise McpError, "#{name} failed: unexpected result: #{response.body.to_s[0, 200]}" unless text.is_a?(String)

  [text, result["isError"] == true]
end
# Read the settings from the environment. The :- and :? forms, unlike - and
# ?, treat an empty variable as unset.
MCP_URL="${MCP_URL:-http://localhost:8080}"
MCP_URL="${MCP_URL%/}"
: "${MCP_TOKEN:?MCP_TOKEN must be set}"

TIMEOUT_SECONDS=30

# Calls a tool with tools/call and prints its result, which holds the text
# of the reply in content[0].text and, if the tool failed, isError set to
# true: call_tool NAME ARGUMENTS_JSON. The server answers a JSON-RPC error
# with HTTP status 200, so the function checks the reply for an error
# member as well as the HTTP status. The token goes to curl in a header
# file that bash supplies through a pipe, so that it never appears in the
# arguments of the curl process, which other users can read in the process
# list.
call_tool() {
    local name="$1" request reply status body
    request=$(jq -nc --arg name "$name" --argjson arguments "$2" \
        '{jsonrpc: "2.0", id: 1, method: "tools/call",
          params: {name: $name, arguments: $arguments}}')
    reply=$(curl --silent --show-error \
        --connect-timeout 10 --max-time "$TIMEOUT_SECONDS" \
        --header @<(printf 'Authorization: Bearer %s\n' "$MCP_TOKEN") \
        --header 'Content-Type: application/json' \
        --write-out '\n%{http_code}' --data-binary "$request" \
        "$MCP_URL/mcp/v1") || return 1
    # --write-out puts the HTTP status on a line of its own at the end.
    status="${reply##*$'\n'}"
    body="${reply%$'\n'*}"
    if [[ "$status" != 200 ]]; then
        echo "$name failed with HTTP $status: ${body:0:200}" >&2
        return 1
    fi
    if ! jq -e '.result.content[0].text or has("error")' >/dev/null 2>&1 \
        <<<"$body"; then
        echo "$name failed: unexpected response: ${body:0:200}" >&2
        return 1
    fi
    if jq -e 'has("error")' >/dev/null <<<"$body"; then
        echo "$name failed: $(jq -r '.error.message' <<<"$body")" >&2
        return 1
    fi
    jq -c '.result' <<<"$body"
}

Running a Query

The query_database tool takes the SQL in its query argument. Its reply is text rather than JSON: it names the database, repeats the query with a LIMIT that the server adds, and then has a line that starts with Results, a header line of column names, and one line for each row, with the values separated by tabs. A tab or a newline inside a value appears as \t or \n, and a NULL as an empty string. The program prints the part after the Results line, with the tabs replaced by commas. The parsing depends on the layout of the text in version 1.1.0, so check it again after an upgrade. The following tabs show how each program runs the query:

def select_cheap_products(client: httpx.Client) -> None:
    """Runs a SELECT with query_database, and prints the column names and
    each row.

    The reply repeats the query, with the LIMIT that the server adds, and
    then has a line such as "Results (3 rows):", a header line, and one
    line for each row, with the values separated by tabs. The server
    writes a tab or a newline inside a value as \\t or \\n, and a NULL as
    an empty string.
    """
    text, failed = call_tool(client, "query_database", {
        "query": "SELECT name, price FROM cookbook_mcp.products "
                 "WHERE price < 100 ORDER BY price",
    })
    if failed:
        raise McpError(f"query_database failed: {text}")
    lines = text.split("\n")
    start = next((i for i, line in enumerate(lines)
                  if line.startswith("Results ")), None)
    if start is None or start + 1 >= len(lines):
        raise McpError(f"Unexpected reply: {text[:200]}")
    print("Columns: " + ", ".join(lines[start + 1].split("\t")))
    for line in lines[start + 2:]:
        if line:
            print("Row: " + ", ".join(line.split("\t")))
// Runs a SELECT with query_database, and prints the column names and each
// row. The reply repeats the query, with the LIMIT that the server adds,
// and then has a line such as "Results (3 rows):", a header line, and one
// line for each row, with the values separated by tabs. The server writes
// a tab or a newline inside a value as \t or \n, and a NULL as an empty
// string.
async function selectCheapProducts(): Promise<void> {
  const { text, failed } = await callTool("query_database", {
    query: "SELECT name, price FROM cookbook_mcp.products WHERE price < 100 ORDER BY price",
  });
  if (failed) {
    throw new Error(`query_database failed: ${text}`);
  }
  const lines = text.split("\n");
  const start = lines.findIndex((line) => line.startsWith("Results "));
  if (start < 0 || start + 1 >= lines.length) {
    throw new Error(`Unexpected reply: ${text.slice(0, 200)}`);
  }
  console.log(`Columns: ${lines[start + 1].split("\t").join(", ")}`);
  for (const line of lines.slice(start + 2)) {
    if (line !== "") {
      console.log(`Row: ${line.split("\t").join(", ")}`);
    }
  }
}
/**
 * Runs a SELECT with query_database, and prints the column names and
 * each row. The reply repeats the query, with the LIMIT that the server
 * adds, and then has a line such as "Results (3 rows):", a header line,
 * and one line for each row, with the values separated by tabs. The
 * server writes a tab or a newline inside a value as \t or \n, and a
 * NULL as an empty string.
 */
static void selectCheapProducts() throws IOException, InterruptedException, McpException {
    ToolResult result = callTool("query_database", Map.of(
            "query", "SELECT name, price FROM cookbook_mcp.products "
                    + "WHERE price < 100 ORDER BY price"));
    if (result.failed()) {
        throw new McpException("query_database failed: " + result.text());
    }
    String[] lines = result.text().split("\n");
    int start = -1;
    for (int i = 0; i < lines.length; i++) {
        if (lines[i].startsWith("Results ")) {
            start = i;
            break;
        }
    }
    if (start < 0 || start + 1 >= lines.length) {
        throw new McpException("Unexpected reply: " + shorten(result.text()));
    }
    System.out.println("Columns: " + lines[start + 1].replace("\t", ", "));
    for (int i = start + 2; i < lines.length; i++) {
        if (!lines[i].isEmpty()) {
            System.out.println("Row: " + lines[i].replace("\t", ", "));
        }
    }
}
// Runs a SELECT with query_database, and prints the column names and each
// row. The reply repeats the query, with the LIMIT that the server adds,
// and then has a line such as "Results (3 rows):", a header line, and one
// line for each row, with the values separated by tabs. The server writes
// a tab or a newline inside a value as \t or \n, and a NULL as an empty
// string.
static async Task SelectCheapProductsAsync(HttpClient client)
{
    var (text, failed) = await CallToolAsync(client, "query_database", new JsonObject
    {
        ["query"] = "SELECT name, price FROM cookbook_mcp.products "
            + "WHERE price < 100 ORDER BY price",
    });
    if (failed)
    {
        throw new McpException($"query_database failed: {text}");
    }
    var lines = text.Split('\n');
    var start = Array.FindIndex(lines, line => line.StartsWith("Results "));
    if (start < 0 || start + 1 >= lines.Length)
    {
        throw new McpException($"Unexpected reply: {Shorten(text)}");
    }
    Console.WriteLine($"Columns: {lines[start + 1].Replace("\t", ", ")}");
    foreach (var line in lines.Skip(start + 2).Where(line => line.Length > 0))
    {
        Console.WriteLine($"Row: {line.Replace("\t", ", ")}");
    }
}
// selectCheapProducts runs a SELECT with query_database, and prints the
// column names and each row. The reply repeats the query, with the LIMIT
// that the server adds, and then has a line such as "Results (3 rows):", a
// header line, and one line for each row, with the values separated by
// tabs. The server writes a tab or a newline inside a value as \t or \n,
// and a NULL as an empty string.
func selectCheapProducts(client *http.Client) error {
    text, failed, err := callTool(client, "query_database", map[string]any{
        "query": "SELECT name, price FROM cookbook_mcp.products WHERE price < 100 ORDER BY price",
    })
    if err != nil {
        return err
    }
    if failed {
        return fmt.Errorf("query_database failed: %s", text)
    }
    lines := strings.Split(text, "\n")
    start := -1
    for i, line := range lines {
        if strings.HasPrefix(line, "Results ") {
            start = i
            break
        }
    }
    if start < 0 || start+1 >= len(lines) {
        return fmt.Errorf("unexpected reply: %s", shorten([]byte(text)))
    }
    fmt.Printf("Columns: %s\n", strings.ReplaceAll(lines[start+1], "\t", ", "))
    for _, line := range lines[start+2:] {
        if line != "" {
            fmt.Printf("Row: %s\n", strings.ReplaceAll(line, "\t", ", "))
        }
    }
    return nil
}
/// Runs a SELECT with query_database, and prints the column names and each
/// row. The reply repeats the query, with the LIMIT that the server adds,
/// and then has a line such as "Results (3 rows):", a header line, and one
/// line for each row, with the values separated by tabs. The server writes
/// a tab or a newline inside a value as \t or \n, and a NULL as an empty
/// string.
fn select_cheap_products(client: &Client) -> Result<(), BoxError> {
    let (text, failed) = call_tool(
        client,
        "query_database",
        json!({
            "query": "SELECT name, price FROM cookbook_mcp.products \
                      WHERE price < 100 ORDER BY price",
        }),
    )?;
    if failed {
        return Err(format!("query_database failed: {text}").into());
    }
    let mut lines = text.split('\n').skip_while(|line| !line.starts_with("Results "));
    let (Some(_), Some(header)) = (lines.next(), lines.next()) else {
        return Err(format!("Unexpected reply: {}", shorten(&text)).into());
    };
    println!("Columns: {}", header.replace('\t', ", "));
    for line in lines.filter(|line| !line.is_empty()) {
        println!("Row: {}", line.replace('\t', ", "));
    }
    Ok(())
}
# Runs a SELECT with query_database, and prints the column names and each
# row. The reply repeats the query, with the LIMIT that the server adds,
# and then has a line such as "Results (3 rows):", a header line, and one
# line for each row, with the values separated by tabs. The server writes a
# tab or a newline inside a value as \t or \n, and a NULL as an empty
# string.
def select_cheap_products(http)
  text, failed = call_tool(http, "query_database", {
                             query: "SELECT name, price FROM cookbook_mcp.products " \
                                    "WHERE price < 100 ORDER BY price"
                           })
  raise McpError, "query_database failed: #{text}" if failed

  lines = text.split("\n")
  start = lines.index { |line| line.start_with?("Results ") }
  raise McpError, "Unexpected reply: #{text[0, 200]}" if start.nil? || start + 1 >= lines.length

  puts "Columns: #{lines[start + 1].gsub("\t", ", ")}"
  lines[(start + 2)..].reject(&:empty?).each do |line|
    puts "Row: #{line.gsub("\t", ", ")}"
  end
end
# Runs a SELECT with query_database, and prints the column names and each
# row. The reply repeats the query, with the LIMIT that the server adds,
# and then has a line such as "Results (3 rows):", a header line, and one
# line for each row, with the values separated by tabs. The server writes
# a tab or a newline inside a value as \t or \n, and a NULL as an empty
# string.
select_cheap_products() {
    local query result
    query="SELECT name, price FROM cookbook_mcp.products
           WHERE price < 100 ORDER BY price"
    result=$(call_tool query_database \
        "$(jq -nc --arg query "$query" '{query: $query}')") || return 1
    if jq -e '.isError == true' >/dev/null <<<"$result"; then
        echo "query_database failed: $(jq -r '.content[0].text' <<<"$result")" >&2
        return 1
    fi
    jq -r '
        .content[0].text | split("\n") as $lines
        | ($lines | map(startswith("Results ")) | index(true)) as $start
        | if $start == null or $start + 1 >= ($lines | length)
          then error("The reply had no results") else . end
        | "Columns: \($lines[$start + 1] | split("\t") | join(", "))",
          ($lines[$start + 2:][] | select(. != "")
           | "Row: \(split("\t") | join(", "))")' <<<"$result" || {
        echo "Unexpected reply: $(jq -r '.content[0].text' <<<"$result" | head -c 200)" >&2
        return 1
    }
}

Trying to Write

The INSERT reaches PostgreSQL, which refuses it with SQLSTATE 25006 because the transaction is read-only, and the tool returns the error with isError set to true. The INSERT leaves out id, which the table requires, so that even a server that allows writes would refuse it rather than save a row. For that reason, each program checks for SQLSTATE 25006 itself rather than for any error, and fails if the write succeeds or fails for another reason. Some errors from the tool start with the query that failed, so each program prints only the last line of the error, which holds the message from PostgreSQL. The following tabs show the attempt:

def try_insert(client: httpx.Client) -> None:
    """Tries to add a row. query_database runs every query in a read-only
    transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
    25006, and the tool returns the error with isError set to true. The
    INSERT leaves out id, which the table requires, so that even a server
    that allows writes would refuse it rather than save a row. That is
    why the function checks for the read-only error itself rather than
    for any error, and fails if the write succeeds or fails for another
    reason. It prints only the last line of the error, which holds
    PostgreSQL's message, because some errors start with the query."""
    text, failed = call_tool(client, "query_database", {
        "query": "INSERT INTO cookbook_mcp.products "
                 "(name, category, price, in_stock, description) "
                 "VALUES ('Acme Test Widget', 'Testing', 1.00, true, "
                 "'A made-up product that is never saved')",
    })
    error_line = text.strip().split("\n")[-1]
    if not failed:
        raise McpError("The server accepted a write")
    if "SQLSTATE 25006" not in text:
        raise McpError(f"The INSERT failed for another reason: {error_line}")
    print(f"INSERT rejected: {error_line}")
// Tries to add a row. query_database runs every query in a read-only
// transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
// 25006, and the tool returns the error with isError set to true. The
// INSERT leaves out id, which the table requires, so that even a server
// that allows writes would refuse it rather than save a row. That is
// why the function checks for the read-only error itself rather than
// for any error, and fails if the write succeeds or fails for another
// reason. It prints only the last line of the error, which holds
// PostgreSQL's message, because some errors start with the query.
async function tryInsert(): Promise<void> {
  const { text, failed } = await callTool("query_database", {
    query:
      "INSERT INTO cookbook_mcp.products " +
      "(name, category, price, in_stock, description) " +
      "VALUES ('Acme Test Widget', 'Testing', 1.00, true, " +
      "'A made-up product that is never saved')",
  });
  const errorLine = text.trim().split("\n").pop() ?? "";
  if (!failed) {
    throw new Error("The server accepted a write");
  }
  if (!text.includes("SQLSTATE 25006")) {
    throw new Error(`The INSERT failed for another reason: ${errorLine}`);
  }
  console.log(`INSERT rejected: ${errorLine}`);
}
/**
 * Tries to add a row. query_database runs every query in a read-only
 * transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
 * 25006, and the tool returns the error with isError set to true. The
 * INSERT leaves out id, which the table requires, so that even a server
 * that allows writes would refuse it rather than save a row. That is
 * why the method checks for the read-only error itself rather than
 * for any error, and fails if the write succeeds or fails for another
 * reason. It prints only the last line of the error, which holds
 * PostgreSQL's message, because some errors start with the query.
 */
static void tryInsert() throws IOException, InterruptedException, McpException {
    ToolResult result = callTool("query_database", Map.of(
            "query", "INSERT INTO cookbook_mcp.products "
                    + "(name, category, price, in_stock, description) "
                    + "VALUES ('Acme Test Widget', 'Testing', 1.00, true, "
                    + "'A made-up product that is never saved')"));
    String[] lines = result.text().strip().split("\n");
    String errorLine = lines[lines.length - 1];
    if (!result.failed()) {
        throw new McpException("The server accepted a write");
    }
    if (!result.text().contains("SQLSTATE 25006")) {
        throw new McpException("The INSERT failed for another reason: " + errorLine);
    }
    System.out.println("INSERT rejected: " + errorLine);
}
// Tries to add a row. query_database runs every query in a read-only
// transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
// 25006, and the tool returns the error with isError set to true. The
// INSERT leaves out id, which the table requires, so that even a server
// that allows writes would refuse it rather than save a row. That is
// why the function checks for the read-only error itself rather than
// for any error, and fails if the write succeeds or fails for another
// reason. It prints only the last line of the error, which holds
// PostgreSQL's message, because some errors start with the query.
static async Task TryInsertAsync(HttpClient client)
{
    var (text, failed) = await CallToolAsync(client, "query_database", new JsonObject
    {
        ["query"] = "INSERT INTO cookbook_mcp.products "
            + "(name, category, price, in_stock, description) "
            + "VALUES ('Acme Test Widget', 'Testing', 1.00, true, "
            + "'A made-up product that is never saved')",
    });
    var errorLine = text.Trim().Split('\n')[^1];
    if (!failed)
    {
        throw new McpException("The server accepted a write");
    }
    if (!text.Contains("SQLSTATE 25006"))
    {
        throw new McpException($"The INSERT failed for another reason: {errorLine}");
    }
    Console.WriteLine($"INSERT rejected: {errorLine}");
}
// tryInsert tries to add a row. query_database runs every query in a
// read-only transaction, so PostgreSQL itself refuses the INSERT with
// SQLSTATE 25006, and the tool returns the error with isError set to true.
// The INSERT leaves out id, which the table requires, so that even a server
// that allows writes would refuse it rather than save a row. That is why
// the function checks for the read-only error itself rather than for any
// error, and fails if the write succeeds or fails for another reason. It
// prints only the last line of the error, which holds PostgreSQL's
// message, because some errors start with the query.
func tryInsert(client *http.Client) error {
    text, failed, err := callTool(client, "query_database", map[string]any{
        "query": "INSERT INTO cookbook_mcp.products " +
            "(name, category, price, in_stock, description) " +
            "VALUES ('Acme Test Widget', 'Testing', 1.00, true, " +
            "'A made-up product that is never saved')",
    })
    if err != nil {
        return err
    }
    lines := strings.Split(strings.TrimSpace(text), "\n")
    errorLine := lines[len(lines)-1]
    if !failed {
        return errors.New("the server accepted a write")
    }
    if !strings.Contains(text, "SQLSTATE 25006") {
        return fmt.Errorf("the INSERT failed for another reason: %s", errorLine)
    }
    fmt.Printf("INSERT rejected: %s\n", errorLine)
    return nil
}
/// Tries to add a row. query_database runs every query in a read-only
/// transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
/// 25006, and the tool returns the error with isError set to true. The
/// INSERT leaves out id, which the table requires, so that even a server
/// that allows writes would refuse it rather than save a row. That is
/// why the function checks for the read-only error itself rather than
/// for any error, and fails if the write succeeds or fails for another
/// reason. It prints only the last line of the error, which holds
/// PostgreSQL's message, because some errors start with the query.
fn try_insert(client: &Client) -> Result<(), BoxError> {
    let (text, failed) = call_tool(
        client,
        "query_database",
        json!({
            "query": "INSERT INTO cookbook_mcp.products \
                      (name, category, price, in_stock, description) \
                      VALUES ('Acme Test Widget', 'Testing', 1.00, true, \
                      'A made-up product that is never saved')",
        }),
    )?;
    let error_line = text.trim().lines().last().unwrap_or("");
    if !failed {
        return Err("The server accepted a write".into());
    }
    if !text.contains("SQLSTATE 25006") {
        return Err(format!("The INSERT failed for another reason: {error_line}").into());
    }
    println!("INSERT rejected: {error_line}");
    Ok(())
}
# Tries to add a row. query_database runs every query in a read-only
# transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
# 25006, and the tool returns the error with isError set to true. The
# INSERT leaves out id, which the table requires, so that even a server
# that allows writes would refuse it rather than save a row. That is
# why the method checks for the read-only error itself rather than
# for any error, and fails if the write succeeds or fails for another
# reason. It prints only the last line of the error, which holds
# PostgreSQL's message, because some errors start with the query.
def try_insert(http)
  text, failed = call_tool(http, "query_database", {
                             query: "INSERT INTO cookbook_mcp.products " \
                                    "(name, category, price, in_stock, description) " \
                                    "VALUES ('Acme Test Widget', 'Testing', 1.00, true, " \
                                    "'A made-up product that is never saved')"
                           })
  error_line = text.strip.split("\n").last.to_s
  raise McpError, "The server accepted a write" unless failed
  unless text.include?("SQLSTATE 25006")
    raise McpError, "The INSERT failed for another reason: #{error_line}"
  end

  puts "INSERT rejected: #{error_line}"
end
# Tries to add a row. query_database runs every query in a read-only
# transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
# 25006, and the tool returns the error with isError set to true. The
# INSERT leaves out id, which the table requires, so that even a server
# that allows writes would refuse it rather than save a row. That is
# why the script checks for the read-only error itself rather than
# for any error, and fails if the write succeeds or fails for another
# reason. It prints only the last line of the error, which holds
# PostgreSQL's message, because some errors start with the query.
try_insert() {
    local query result text error_line
    query="INSERT INTO cookbook_mcp.products
               (name, category, price, in_stock, description)
           VALUES ('Acme Test Widget', 'Testing', 1.00, true,
                   'A made-up product that is never saved')"
    result=$(call_tool query_database \
        "$(jq -nc --arg query "$query" '{query: $query}')") || return 1
    if ! jq -e '.isError == true' >/dev/null <<<"$result"; then
        echo "The server accepted a write" >&2
        return 1
    fi
    text=$(jq -r '.content[0].text' <<<"$result")
    error_line="${text##*$'\n'}"
    if [[ "$text" != *"SQLSTATE 25006"* ]]; then
        echo "The INSERT failed for another reason: $error_line" >&2
        return 1
    fi
    echo "INSERT rejected: $error_line"
}

Handling Errors

The main part of each program checks that a token is set, runs both steps, reports any failure on stderr, and exits with a non-zero status. The following tabs show the main part of each program:

def main() -> int:
    if not TOKEN:
        print("MCP_TOKEN must be set", file=sys.stderr)
        return 1
    try:
        with httpx.Client(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as client:
            select_cheap_products(client)
            try_insert(client)
    # httpx.HTTPError is the base class for failures to reach the server,
    # such as a refused connection or a timeout. KeyError, IndexError,
    # TypeError, AttributeError and ValueError cover a reply that lacks the
    # members the program reads, or holds one of the wrong type or form.
    except (httpx.HTTPError, McpError, KeyError, IndexError, TypeError,
            AttributeError, ValueError) as e:
        print(f"MCP Server error: {e}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
// Returns a readable description of an error. fetch reports a connection
// failure as a TypeError with the cause (often an AggregateError, whose
// own message is empty) attached.
function describe(err: unknown): string {
  if (err instanceof AggregateError) {
    return err.errors.map(describe).join("; ");
  }
  if (err instanceof Error) {
    return err.cause ? `${err.message}: ${describe(err.cause)}` : err.message;
  }
  return String(err);
}

async function main(): Promise<number> {
  if (!token) {
    console.error("MCP_TOKEN must be set");
    return 1;
  }
  try {
    await selectCheapProducts();
    await tryInsert();
    return 0;
  } catch (err) {
    console.error(`MCP Server error: ${describe(err)}`);
    return 1;
  }
}

process.exitCode = await main();
public static void main(String[] args) {
    if (TOKEN.isEmpty()) {
        System.err.println("MCP_TOKEN must be set");
        System.exit(1);
    }
    try {
        selectCheapProducts();
        tryInsert();
    // IOException covers failures to reach the server, such as a
    // refused connection or a timeout. The message of a refused
    // connection can be empty, so print the exception itself, which
    // includes its type.
    } catch (IOException | InterruptedException e) {
        System.err.println("MCP Server error: " + e);
        System.exit(1);
    } catch (McpException e) {
        System.err.println("MCP Server error: " + e.getMessage());
        System.exit(1);
    }
}
if (Token().Length == 0)
{
    Console.Error.WriteLine("MCP_TOKEN must be set");
    return 1;
}
try
{
    // using disposes of the client, and closes its connections, when the
    // block exits, whether or not an error occurred.
    using var client = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
    };
    await SelectCheapProductsAsync(client);
    await TryInsertAsync(client);
}
// HttpRequestException covers failures to reach the server, such as a
// refused connection, and TaskCanceledException covers a timeout.
catch (Exception e) when (
    e is HttpRequestException or TaskCanceledException or McpException)
{
    Console.Error.WriteLine($"MCP Server error: {e.Message}");
    return 1;
}
return 0;

// C# requires type declarations to follow the top-level statements, so
// the exception that the program throws for an error reply is declared
// here, at the end.
class McpException(string message) : Exception(message);
// run makes the two calls and returns any error to the caller.
func run() error {
    if token == "" {
        return errors.New("MCP_TOKEN must be set")
    }
    client := &http.Client{Timeout: timeout}
    if err := selectCheapProducts(client); err != nil {
        return err
    }
    return tryInsert(client)
}

// main reports any error returned by run() and exits with a non-zero
// status.
func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "MCP Server error: %v\n", err)
        os.Exit(1)
    }
}
/// Makes the two calls. The ? operator returns any error to the caller.
fn run() -> Result<(), BoxError> {
    if token().is_empty() {
        return Err("MCP_TOKEN must be set".into());
    }
    let client = Client::builder().timeout(TIMEOUT).build()?;
    select_cheap_products(&client)?;
    try_insert(&client)
}

/// Reports any error returned by run() and exits with a failure status.
/// The top-level error is often generic ("error sending request"), so the
/// chain of underlying causes is printed too.
fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("MCP Server error: {e}");
            let mut cause = e.source();
            while let Some(c) = cause {
                eprintln!("  caused by: {c}");
                cause = c.source();
            }
            ExitCode::FAILURE
        }
    }
}
if TOKEN.empty?
  warn "MCP_TOKEN must be set"
  exit 1
end

begin
  uri = URI(BASE_URL)
  # start opens a connection, and closes it when the block exits, whether
  # or not an error occurred.
  Net::HTTP.start(
    uri.host, uri.port,
    use_ssl: uri.scheme == "https",
    open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS
  ) do |http|
    select_cheap_products(http)
    try_insert(http)
  end
# SystemCallError covers a refused connection, Timeout::Error covers the
# connection and read timeouts, and SocketError covers an unknown host.
rescue McpError, SystemCallError, Timeout::Error, SocketError, IOError => e
  warn "MCP Server error: #{e.message}"
  exit 1
end
main() {
    select_cheap_products
    try_insert
}

main

Running the Example

The complete project for each language is available in the cookbook repository. Set MCP_URL and MCP_TOKEN to the values that scripts/start_postgres_mcp.sh prints, or to those of your own server, and then run the command for your language from the corresponding project directory. The following tabs show the commands that build and run each example:

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python main.py
npm install
npm run build
npm start
mvn compile exec:java
dotnet run
go run .
cargo run
bundle install
bundle exec ruby main.rb
bash main.sh

Viewing the Complete Program

The following tabs show the complete program for each language:

"""
Ellie's Postgres Cookbook
Running read-only queries through the pgEdge Postgres MCP Server, using
httpx.

These examples are for pgEdge Postgres MCP Server 1.1.0.

Copyright (c) 2026, pgEdge, Inc.
This code is released under the PostgreSQL License.
"""

import os
import sys

import httpx


# The or operator treats an empty variable as unset.
BASE_URL = (os.environ.get("MCP_URL") or "http://localhost:8080").rstrip("/")
TOKEN = os.environ.get("MCP_TOKEN") or ""

TIMEOUT_SECONDS = 30


class McpError(Exception):
    """Raised when the MCP Server rejects a request or replies oddly."""


def call_tool(client: httpx.Client, name: str,
              arguments: dict) -> tuple[str, bool]:
    """Calls a tool with tools/call and returns the text of its reply and
    whether the tool failed, which it reports by setting isError.

    The server answers a JSON-RPC error with HTTP status 200, so the
    function checks the reply for an error member as well as the HTTP
    status.
    """
    response = client.post(
        "/mcp/v1",
        headers={"Authorization": f"Bearer {TOKEN}"},
        json={"jsonrpc": "2.0", "id": 1, "method": "tools/call",
              "params": {"name": name, "arguments": arguments}},
    )
    if response.status_code != 200:
        raise McpError(f"{name} failed with HTTP {response.status_code}: "
                       f"{response.text[:200]}")
    try:
        reply = response.json()
    except ValueError:
        reply = None
    if not isinstance(reply, dict) or not ("result" in reply or "error" in reply):
        raise McpError(f"{name} failed: unexpected response: "
                       f"{response.text[:200]}")
    if "error" in reply:
        raise McpError(f"{name} failed: {reply['error'].get('message')}")
    result = reply["result"]
    return result["content"][0]["text"], bool(result.get("isError"))


def select_cheap_products(client: httpx.Client) -> None:
    """Runs a SELECT with query_database, and prints the column names and
    each row.

    The reply repeats the query, with the LIMIT that the server adds, and
    then has a line such as "Results (3 rows):", a header line, and one
    line for each row, with the values separated by tabs. The server
    writes a tab or a newline inside a value as \\t or \\n, and a NULL as
    an empty string.
    """
    text, failed = call_tool(client, "query_database", {
        "query": "SELECT name, price FROM cookbook_mcp.products "
                 "WHERE price < 100 ORDER BY price",
    })
    if failed:
        raise McpError(f"query_database failed: {text}")
    lines = text.split("\n")
    start = next((i for i, line in enumerate(lines)
                  if line.startswith("Results ")), None)
    if start is None or start + 1 >= len(lines):
        raise McpError(f"Unexpected reply: {text[:200]}")
    print("Columns: " + ", ".join(lines[start + 1].split("\t")))
    for line in lines[start + 2:]:
        if line:
            print("Row: " + ", ".join(line.split("\t")))


def try_insert(client: httpx.Client) -> None:
    """Tries to add a row. query_database runs every query in a read-only
    transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
    25006, and the tool returns the error with isError set to true. The
    INSERT leaves out id, which the table requires, so that even a server
    that allows writes would refuse it rather than save a row. That is
    why the function checks for the read-only error itself rather than
    for any error, and fails if the write succeeds or fails for another
    reason. It prints only the last line of the error, which holds
    PostgreSQL's message, because some errors start with the query."""
    text, failed = call_tool(client, "query_database", {
        "query": "INSERT INTO cookbook_mcp.products "
                 "(name, category, price, in_stock, description) "
                 "VALUES ('Acme Test Widget', 'Testing', 1.00, true, "
                 "'A made-up product that is never saved')",
    })
    error_line = text.strip().split("\n")[-1]
    if not failed:
        raise McpError("The server accepted a write")
    if "SQLSTATE 25006" not in text:
        raise McpError(f"The INSERT failed for another reason: {error_line}")
    print(f"INSERT rejected: {error_line}")


def main() -> int:
    if not TOKEN:
        print("MCP_TOKEN must be set", file=sys.stderr)
        return 1
    try:
        with httpx.Client(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as client:
            select_cheap_products(client)
            try_insert(client)
    # httpx.HTTPError is the base class for failures to reach the server,
    # such as a refused connection or a timeout. KeyError, IndexError,
    # TypeError, AttributeError and ValueError cover a reply that lacks the
    # members the program reads, or holds one of the wrong type or form.
    except (httpx.HTTPError, McpError, KeyError, IndexError, TypeError,
            AttributeError, ValueError) as e:
        print(f"MCP Server error: {e}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
/*
 * Ellie's Postgres Cookbook
 * Running read-only queries through the pgEdge Postgres MCP Server, using
 * the built-in fetch.
 *
 * These examples are for pgEdge Postgres MCP Server 1.1.0.
 *
 * Copyright (c) 2026, pgEdge, Inc.
 * This code is released under the PostgreSQL License.
 */

// Node.js provides fetch and AbortSignal as globals, so this program
// imports nothing.

// The || operator, unlike ??, also treats an empty variable as unset.
const baseUrl = (process.env.MCP_URL || "http://localhost:8080").replace(/\/+$/, "");
const token = process.env.MCP_TOKEN || "";

const timeoutMs = 30_000;

// Calls a tool with tools/call and returns the text of its reply and
// whether the tool failed, which it reports by setting isError. The server
// answers a JSON-RPC error with HTTP status 200, so the function checks the
// reply for an error member as well as the HTTP status.
async function callTool(
  name: string,
  args: object,
): Promise<{ text: string; failed: boolean }> {
  const response = await fetch(`${baseUrl}/mcp/v1`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name, arguments: args },
    }),
    signal: AbortSignal.timeout(timeoutMs),
  });
  const body = await response.text();
  if (response.status !== 200) {
    throw new Error(`${name} failed with HTTP ${response.status}: ${body.slice(0, 200)}`);
  }
  let reply: any;
  try {
    reply = JSON.parse(body);
  } catch {
    reply = undefined;
  }
  if (typeof reply !== "object" || reply === null || !("result" in reply || "error" in reply)) {
    throw new Error(`${name} failed: unexpected response: ${body.slice(0, 200)}`);
  }
  if ("error" in reply) {
    throw new Error(`${name} failed: ${reply.error?.message}`);
  }
  const text = reply.result?.content?.[0]?.text;
  if (typeof text !== "string") {
    throw new Error(`${name} failed: unexpected result: ${body.slice(0, 200)}`);
  }
  return { text, failed: reply.result.isError === true };
}

// Runs a SELECT with query_database, and prints the column names and each
// row. The reply repeats the query, with the LIMIT that the server adds,
// and then has a line such as "Results (3 rows):", a header line, and one
// line for each row, with the values separated by tabs. The server writes
// a tab or a newline inside a value as \t or \n, and a NULL as an empty
// string.
async function selectCheapProducts(): Promise<void> {
  const { text, failed } = await callTool("query_database", {
    query: "SELECT name, price FROM cookbook_mcp.products WHERE price < 100 ORDER BY price",
  });
  if (failed) {
    throw new Error(`query_database failed: ${text}`);
  }
  const lines = text.split("\n");
  const start = lines.findIndex((line) => line.startsWith("Results "));
  if (start < 0 || start + 1 >= lines.length) {
    throw new Error(`Unexpected reply: ${text.slice(0, 200)}`);
  }
  console.log(`Columns: ${lines[start + 1].split("\t").join(", ")}`);
  for (const line of lines.slice(start + 2)) {
    if (line !== "") {
      console.log(`Row: ${line.split("\t").join(", ")}`);
    }
  }
}

// Tries to add a row. query_database runs every query in a read-only
// transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
// 25006, and the tool returns the error with isError set to true. The
// INSERT leaves out id, which the table requires, so that even a server
// that allows writes would refuse it rather than save a row. That is
// why the function checks for the read-only error itself rather than
// for any error, and fails if the write succeeds or fails for another
// reason. It prints only the last line of the error, which holds
// PostgreSQL's message, because some errors start with the query.
async function tryInsert(): Promise<void> {
  const { text, failed } = await callTool("query_database", {
    query:
      "INSERT INTO cookbook_mcp.products " +
      "(name, category, price, in_stock, description) " +
      "VALUES ('Acme Test Widget', 'Testing', 1.00, true, " +
      "'A made-up product that is never saved')",
  });
  const errorLine = text.trim().split("\n").pop() ?? "";
  if (!failed) {
    throw new Error("The server accepted a write");
  }
  if (!text.includes("SQLSTATE 25006")) {
    throw new Error(`The INSERT failed for another reason: ${errorLine}`);
  }
  console.log(`INSERT rejected: ${errorLine}`);
}

// Returns a readable description of an error. fetch reports a connection
// failure as a TypeError with the cause (often an AggregateError, whose
// own message is empty) attached.
function describe(err: unknown): string {
  if (err instanceof AggregateError) {
    return err.errors.map(describe).join("; ");
  }
  if (err instanceof Error) {
    return err.cause ? `${err.message}: ${describe(err.cause)}` : err.message;
  }
  return String(err);
}

async function main(): Promise<number> {
  if (!token) {
    console.error("MCP_TOKEN must be set");
    return 1;
  }
  try {
    await selectCheapProducts();
    await tryInsert();
    return 0;
  } catch (err) {
    console.error(`MCP Server error: ${describe(err)}`);
    return 1;
  }
}

process.exitCode = await main();
/*
 * Ellie's Postgres Cookbook
 * Running read-only queries through the pgEdge Postgres MCP Server, using
 * java.net.http and Jackson.
 *
 * These examples are for pgEdge Postgres MCP Server 1.1.0.
 *
 * Copyright (c) 2026, pgEdge, Inc.
 * This code is released under the PostgreSQL License.
 */

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;

public class Main {

    /** Returns the value of an environment variable, or a fallback. */
    static String envOr(String name, String fallback) {
        String value = System.getenv(name);
        return (value != null && !value.isEmpty()) ? value : fallback;
    }

    static final String BASE_URL =
            envOr("MCP_URL", "http://localhost:8080").replaceAll("/+$", "");
    static final String TOKEN = envOr("MCP_TOKEN", "");

    static final Duration TIMEOUT = Duration.ofSeconds(30);

    /** Thrown when the MCP Server rejects a request or replies oddly. */
    static class McpException extends Exception {
        McpException(String message) {
            super(message);
        }
    }

    /** The text of a tool's reply, and whether the tool failed. */
    record ToolResult(String text, boolean failed) {
    }

    // A client and a mapper are safe to share, so the program creates
    // only one of each.
    static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(TIMEOUT)
            .build();
    static final ObjectMapper MAPPER = JsonMapper.builder().build();

    /** Returns at most the first 200 characters of a reply. */
    static String shorten(String text) {
        return text.length() > 200 ? text.substring(0, 200) : text;
    }

    /**
     * Sends a request and returns the response with its whole body. The
     * request timeout stops applying once the headers arrive, so a server
     * that stalls part of the way through the body would hang send(); this
     * applies the timeout to the whole exchange instead.
     */
    static HttpResponse<String> send(HttpRequest request)
            throws IOException, InterruptedException {
        CompletableFuture<HttpResponse<String>> pending =
                CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString());
        try {
            return pending.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            pending.cancel(true);
            throw new HttpTimeoutException("request timed out");
        } catch (ExecutionException e) {
            if (e.getCause() instanceof IOException cause) {
                throw cause;
            }
            throw new IOException(e.getCause());
        }
    }

    /** Parses a reply as a JSON object, or returns null if it is not one. */
    static JsonNode parseObject(String text) {
        try {
            JsonNode node = MAPPER.readTree(text);
            return node.isObject() ? node : null;
        } catch (JacksonException e) {
            return null;
        }
    }

    /**
     * Calls a tool with tools/call and returns the text of its reply and
     * whether the tool failed, which it reports by setting isError. The
     * server answers a JSON-RPC error with HTTP status 200, so the method
     * checks the reply for an error member as well as the HTTP status.
     */
    static ToolResult callTool(String name, Map<String, Object> arguments)
            throws IOException, InterruptedException, McpException {
        String message = MAPPER.writeValueAsString(Map.of(
                "jsonrpc", "2.0", "id", 1, "method", "tools/call",
                "params", Map.of("name", name, "arguments", arguments)));
        HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/mcp/v1"))
                .timeout(TIMEOUT)
                .header("Authorization", "Bearer " + TOKEN)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(message))
                .build();
        HttpResponse<String> response = send(request);
        if (response.statusCode() != 200) {
            throw new McpException(name + " failed with HTTP "
                    + response.statusCode() + ": " + shorten(response.body()));
        }
        JsonNode reply = parseObject(response.body());
        if (reply == null || !(reply.has("result") || reply.has("error"))) {
            throw new McpException(name + " failed: unexpected response: "
                    + shorten(response.body()));
        }
        if (reply.has("error")) {
            throw new McpException(name + " failed: "
                    + reply.path("error").path("message").asString());
        }
        JsonNode result = reply.path("result");
        JsonNode text = result.path("content").path(0).path("text");
        if (!text.isString()) {
            throw new McpException(name + " failed: unexpected result: "
                    + shorten(response.body()));
        }
        return new ToolResult(text.stringValue(), result.path("isError").asBoolean(false));
    }

    /**
     * Runs a SELECT with query_database, and prints the column names and
     * each row. The reply repeats the query, with the LIMIT that the server
     * adds, and then has a line such as "Results (3 rows):", a header line,
     * and one line for each row, with the values separated by tabs. The
     * server writes a tab or a newline inside a value as \t or \n, and a
     * NULL as an empty string.
     */
    static void selectCheapProducts() throws IOException, InterruptedException, McpException {
        ToolResult result = callTool("query_database", Map.of(
                "query", "SELECT name, price FROM cookbook_mcp.products "
                        + "WHERE price < 100 ORDER BY price"));
        if (result.failed()) {
            throw new McpException("query_database failed: " + result.text());
        }
        String[] lines = result.text().split("\n");
        int start = -1;
        for (int i = 0; i < lines.length; i++) {
            if (lines[i].startsWith("Results ")) {
                start = i;
                break;
            }
        }
        if (start < 0 || start + 1 >= lines.length) {
            throw new McpException("Unexpected reply: " + shorten(result.text()));
        }
        System.out.println("Columns: " + lines[start + 1].replace("\t", ", "));
        for (int i = start + 2; i < lines.length; i++) {
            if (!lines[i].isEmpty()) {
                System.out.println("Row: " + lines[i].replace("\t", ", "));
            }
        }
    }

    /**
     * Tries to add a row. query_database runs every query in a read-only
     * transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
     * 25006, and the tool returns the error with isError set to true. The
     * INSERT leaves out id, which the table requires, so that even a server
     * that allows writes would refuse it rather than save a row. That is
     * why the method checks for the read-only error itself rather than
     * for any error, and fails if the write succeeds or fails for another
     * reason. It prints only the last line of the error, which holds
     * PostgreSQL's message, because some errors start with the query.
     */
    static void tryInsert() throws IOException, InterruptedException, McpException {
        ToolResult result = callTool("query_database", Map.of(
                "query", "INSERT INTO cookbook_mcp.products "
                        + "(name, category, price, in_stock, description) "
                        + "VALUES ('Acme Test Widget', 'Testing', 1.00, true, "
                        + "'A made-up product that is never saved')"));
        String[] lines = result.text().strip().split("\n");
        String errorLine = lines[lines.length - 1];
        if (!result.failed()) {
            throw new McpException("The server accepted a write");
        }
        if (!result.text().contains("SQLSTATE 25006")) {
            throw new McpException("The INSERT failed for another reason: " + errorLine);
        }
        System.out.println("INSERT rejected: " + errorLine);
    }

    public static void main(String[] args) {
        if (TOKEN.isEmpty()) {
            System.err.println("MCP_TOKEN must be set");
            System.exit(1);
        }
        try {
            selectCheapProducts();
            tryInsert();
        // IOException covers failures to reach the server, such as a
        // refused connection or a timeout. The message of a refused
        // connection can be empty, so print the exception itself, which
        // includes its type.
        } catch (IOException | InterruptedException e) {
            System.err.println("MCP Server error: " + e);
            System.exit(1);
        } catch (McpException e) {
            System.err.println("MCP Server error: " + e.getMessage());
            System.exit(1);
        }
    }
}
/*
 * Ellie's Postgres Cookbook
 * Running read-only queries through the pgEdge Postgres MCP Server, using
 * HttpClient and System.Text.Json.
 *
 * These examples are for pgEdge Postgres MCP Server 1.1.0.
 *
 * Copyright (c) 2026, pgEdge, Inc.
 * This code is released under the PostgreSQL License.
 */

using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

// Returns the value of an environment variable, or a fallback if the
// variable is unset or empty.
static string EnvOr(string name, string fallback) =>
    Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
        ? value
        : fallback;

static string BaseUrl() => EnvOr("MCP_URL", "http://localhost:8080").TrimEnd('/');
static string Token() => EnvOr("MCP_TOKEN", "");

const int TimeoutSeconds = 30;

// Returns at most the first 200 characters of a reply.
static string Shorten(string text) =>
    text.Length > 200 ? text[..200] : text;

// Parses a reply as a JSON object, or returns null if it is not one.
static JsonObject? ParseObject(string text)
{
    try
    {
        return JsonNode.Parse(text) as JsonObject;
    }
    catch (JsonException)
    {
        return null;
    }
}

// Calls a tool with tools/call and returns the text of its reply and
// whether the tool failed, which it reports by setting isError. The server
// answers a JSON-RPC error with HTTP status 200, so the function checks the
// reply for an error member as well as the HTTP status.
static async Task<(string Text, bool Failed)> CallToolAsync(
    HttpClient client, string name, JsonObject arguments)
{
    var message = new JsonObject
    {
        ["jsonrpc"] = "2.0",
        ["id"] = 1,
        ["method"] = "tools/call",
        ["params"] = new JsonObject { ["name"] = name, ["arguments"] = arguments },
    };
    using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl() + "/mcp/v1")
    {
        Content = new StringContent(
            message.ToJsonString(), Encoding.UTF8, "application/json"),
    };
    request.Headers.Add("Authorization", $"Bearer {Token()}");
    using var response = await client.SendAsync(request);
    var body = await response.Content.ReadAsStringAsync();
    if (response.StatusCode != HttpStatusCode.OK)
    {
        throw new McpException(
            $"{name} failed with HTTP {(int)response.StatusCode}: {Shorten(body)}");
    }
    var reply = ParseObject(body);
    if (reply is null || !(reply.ContainsKey("result") || reply.ContainsKey("error")))
    {
        throw new McpException($"{name} failed: unexpected response: {Shorten(body)}");
    }
    if (reply.ContainsKey("error"))
    {
        throw new McpException($"{name} failed: {reply["error"]?["message"]}");
    }
    var result = reply["result"];
    if (result?["content"]?[0]?["text"] is not JsonValue textValue ||
        !textValue.TryGetValue(out string? text))
    {
        throw new McpException($"{name} failed: unexpected result: {Shorten(body)}");
    }
    var failed = result["isError"] is JsonValue isError &&
        isError.TryGetValue(out bool flag) && flag;
    return (text, failed);
}

// Runs a SELECT with query_database, and prints the column names and each
// row. The reply repeats the query, with the LIMIT that the server adds,
// and then has a line such as "Results (3 rows):", a header line, and one
// line for each row, with the values separated by tabs. The server writes
// a tab or a newline inside a value as \t or \n, and a NULL as an empty
// string.
static async Task SelectCheapProductsAsync(HttpClient client)
{
    var (text, failed) = await CallToolAsync(client, "query_database", new JsonObject
    {
        ["query"] = "SELECT name, price FROM cookbook_mcp.products "
            + "WHERE price < 100 ORDER BY price",
    });
    if (failed)
    {
        throw new McpException($"query_database failed: {text}");
    }
    var lines = text.Split('\n');
    var start = Array.FindIndex(lines, line => line.StartsWith("Results "));
    if (start < 0 || start + 1 >= lines.Length)
    {
        throw new McpException($"Unexpected reply: {Shorten(text)}");
    }
    Console.WriteLine($"Columns: {lines[start + 1].Replace("\t", ", ")}");
    foreach (var line in lines.Skip(start + 2).Where(line => line.Length > 0))
    {
        Console.WriteLine($"Row: {line.Replace("\t", ", ")}");
    }
}

// Tries to add a row. query_database runs every query in a read-only
// transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
// 25006, and the tool returns the error with isError set to true. The
// INSERT leaves out id, which the table requires, so that even a server
// that allows writes would refuse it rather than save a row. That is
// why the function checks for the read-only error itself rather than
// for any error, and fails if the write succeeds or fails for another
// reason. It prints only the last line of the error, which holds
// PostgreSQL's message, because some errors start with the query.
static async Task TryInsertAsync(HttpClient client)
{
    var (text, failed) = await CallToolAsync(client, "query_database", new JsonObject
    {
        ["query"] = "INSERT INTO cookbook_mcp.products "
            + "(name, category, price, in_stock, description) "
            + "VALUES ('Acme Test Widget', 'Testing', 1.00, true, "
            + "'A made-up product that is never saved')",
    });
    var errorLine = text.Trim().Split('\n')[^1];
    if (!failed)
    {
        throw new McpException("The server accepted a write");
    }
    if (!text.Contains("SQLSTATE 25006"))
    {
        throw new McpException($"The INSERT failed for another reason: {errorLine}");
    }
    Console.WriteLine($"INSERT rejected: {errorLine}");
}

if (Token().Length == 0)
{
    Console.Error.WriteLine("MCP_TOKEN must be set");
    return 1;
}
try
{
    // using disposes of the client, and closes its connections, when the
    // block exits, whether or not an error occurred.
    using var client = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
    };
    await SelectCheapProductsAsync(client);
    await TryInsertAsync(client);
}
// HttpRequestException covers failures to reach the server, such as a
// refused connection, and TaskCanceledException covers a timeout.
catch (Exception e) when (
    e is HttpRequestException or TaskCanceledException or McpException)
{
    Console.Error.WriteLine($"MCP Server error: {e.Message}");
    return 1;
}
return 0;

// C# requires type declarations to follow the top-level statements, so
// the exception that the program throws for an error reply is declared
// here, at the end.
class McpException(string message) : Exception(message);
// Ellie's Postgres Cookbook
// Running read-only queries through the pgEdge Postgres MCP Server, using
// net/http.
//
// These examples are for pgEdge Postgres MCP Server 1.1.0.
//
// Copyright (c) 2026, pgEdge, Inc.
// This code is released under the PostgreSQL License.

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
)


// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

var (
    baseURL = strings.TrimRight(envOr("MCP_URL", "http://localhost:8080"), "/")
    token   = os.Getenv("MCP_TOKEN")
)

const timeout = 30 * time.Second

// shorten returns at most the first 200 bytes of a reply.
func shorten(body []byte) string {
    if len(body) > 200 {
        body = body[:200]
    }
    return string(body)
}

// callTool calls a tool with tools/call and returns the text of its reply
// and whether the tool failed, which it reports by setting isError. The
// server answers a JSON-RPC error with HTTP status 200, so the function
// checks the reply for an error member as well as the HTTP status.
func callTool(client *http.Client, name string, arguments map[string]any) (string, bool, error) {
    payload, err := json.Marshal(map[string]any{
        "jsonrpc": "2.0", "id": 1, "method": "tools/call",
        "params": map[string]any{"name": name, "arguments": arguments},
    })
    if err != nil {
        return "", false, err
    }
    request, err := http.NewRequest(http.MethodPost, baseURL+"/mcp/v1", bytes.NewReader(payload))
    if err != nil {
        return "", false, err
    }
    request.Header.Set("Authorization", "Bearer "+token)
    request.Header.Set("Content-Type", "application/json")

    response, err := client.Do(request)
    if err != nil {
        return "", false, err
    }
    defer response.Body.Close()
    body, err := io.ReadAll(response.Body)
    if err != nil {
        return "", false, err
    }
    if response.StatusCode != http.StatusOK {
        return "", false, fmt.Errorf("%s failed with HTTP %d: %s", name, response.StatusCode, shorten(body))
    }
    var reply struct {
        Result *struct {
            Content []struct {
                Text string `json:"text"`
            } `json:"content"`
            IsError bool `json:"isError"`
        } `json:"result"`
        Error *struct {
            Message string `json:"message"`
        } `json:"error"`
    }
    if err := json.Unmarshal(body, &reply); err != nil || (reply.Result == nil && reply.Error == nil) {
        return "", false, fmt.Errorf("%s failed: unexpected response: %s", name, shorten(body))
    }
    if reply.Error != nil {
        return "", false, fmt.Errorf("%s failed: %s", name, reply.Error.Message)
    }
    if len(reply.Result.Content) == 0 {
        return "", false, fmt.Errorf("%s failed: unexpected result: %s", name, shorten(body))
    }
    return reply.Result.Content[0].Text, reply.Result.IsError, nil
}


// selectCheapProducts runs a SELECT with query_database, and prints the
// column names and each row. The reply repeats the query, with the LIMIT
// that the server adds, and then has a line such as "Results (3 rows):", a
// header line, and one line for each row, with the values separated by
// tabs. The server writes a tab or a newline inside a value as \t or \n,
// and a NULL as an empty string.
func selectCheapProducts(client *http.Client) error {
    text, failed, err := callTool(client, "query_database", map[string]any{
        "query": "SELECT name, price FROM cookbook_mcp.products WHERE price < 100 ORDER BY price",
    })
    if err != nil {
        return err
    }
    if failed {
        return fmt.Errorf("query_database failed: %s", text)
    }
    lines := strings.Split(text, "\n")
    start := -1
    for i, line := range lines {
        if strings.HasPrefix(line, "Results ") {
            start = i
            break
        }
    }
    if start < 0 || start+1 >= len(lines) {
        return fmt.Errorf("unexpected reply: %s", shorten([]byte(text)))
    }
    fmt.Printf("Columns: %s\n", strings.ReplaceAll(lines[start+1], "\t", ", "))
    for _, line := range lines[start+2:] {
        if line != "" {
            fmt.Printf("Row: %s\n", strings.ReplaceAll(line, "\t", ", "))
        }
    }
    return nil
}


// tryInsert tries to add a row. query_database runs every query in a
// read-only transaction, so PostgreSQL itself refuses the INSERT with
// SQLSTATE 25006, and the tool returns the error with isError set to true.
// The INSERT leaves out id, which the table requires, so that even a server
// that allows writes would refuse it rather than save a row. That is why
// the function checks for the read-only error itself rather than for any
// error, and fails if the write succeeds or fails for another reason. It
// prints only the last line of the error, which holds PostgreSQL's
// message, because some errors start with the query.
func tryInsert(client *http.Client) error {
    text, failed, err := callTool(client, "query_database", map[string]any{
        "query": "INSERT INTO cookbook_mcp.products " +
            "(name, category, price, in_stock, description) " +
            "VALUES ('Acme Test Widget', 'Testing', 1.00, true, " +
            "'A made-up product that is never saved')",
    })
    if err != nil {
        return err
    }
    lines := strings.Split(strings.TrimSpace(text), "\n")
    errorLine := lines[len(lines)-1]
    if !failed {
        return errors.New("the server accepted a write")
    }
    if !strings.Contains(text, "SQLSTATE 25006") {
        return fmt.Errorf("the INSERT failed for another reason: %s", errorLine)
    }
    fmt.Printf("INSERT rejected: %s\n", errorLine)
    return nil
}


// run makes the two calls and returns any error to the caller.
func run() error {
    if token == "" {
        return errors.New("MCP_TOKEN must be set")
    }
    client := &http.Client{Timeout: timeout}
    if err := selectCheapProducts(client); err != nil {
        return err
    }
    return tryInsert(client)
}

// main reports any error returned by run() and exits with a non-zero
// status.
func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "MCP Server error: %v\n", err)
        os.Exit(1)
    }
}
// Ellie's Postgres Cookbook
// Running read-only queries through the pgEdge Postgres MCP Server, using
// reqwest and serde_json.
//
// These examples are for pgEdge Postgres MCP Server 1.1.0.
//
// Copyright (c) 2026, pgEdge, Inc.
// This code is released under the PostgreSQL License.

use std::env;
use std::error::Error;
use std::process::ExitCode;
use std::time::Duration;

use reqwest::blocking::Client;
use serde_json::{json, Value};

/// Returns the value of an environment variable, or a fallback if the
/// variable is unset or empty.
fn env_or(name: &str, fallback: &str) -> String {
    env::var(name)
        .ok()
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| fallback.to_string())
}

fn base_url() -> String {
    env_or("MCP_URL", "http://localhost:8080")
        .trim_end_matches('/')
        .to_string()
}

fn token() -> String {
    env_or("MCP_TOKEN", "")
}

const TIMEOUT: Duration = Duration::from_secs(30);

/// The errors that the functions below return; a string converts into one
/// with the ? operator or into().
type BoxError = Box<dyn Error>;

/// Returns at most the first 200 characters of a reply.
fn shorten(text: &str) -> String {
    text.chars().take(200).collect()
}

/// Calls a tool with tools/call and returns the text of its reply and
/// whether the tool failed, which it reports by setting isError. The
/// server answers a JSON-RPC error with HTTP status 200, so the function
/// checks the reply for an error member as well as the HTTP status.
fn call_tool(client: &Client, name: &str, arguments: Value) -> Result<(String, bool), BoxError> {
    let message = json!({
        "jsonrpc": "2.0", "id": 1, "method": "tools/call",
        "params": {"name": name, "arguments": arguments},
    });
    let response = client
        .post(format!("{}/mcp/v1", base_url()))
        .bearer_auth(token())
        .json(&message)
        .send()?;
    let status = response.status().as_u16();
    let body = response.text()?;
    if status != 200 {
        return Err(format!("{name} failed with HTTP {status}: {}", shorten(&body)).into());
    }
    let reply = match serde_json::from_str::<Value>(&body) {
        Ok(reply) if reply.get("result").is_some() || reply.get("error").is_some() => reply,
        _ => return Err(format!("{name} failed: unexpected response: {}", shorten(&body)).into()),
    };
    if let Some(error) = reply.get("error") {
        let message = error["message"].as_str().unwrap_or("unknown error");
        return Err(format!("{name} failed: {message}").into());
    }
    let result = &reply["result"];
    let Some(text) = result["content"][0]["text"].as_str() else {
        return Err(format!("{name} failed: unexpected result: {}", shorten(&body)).into());
    };
    Ok((text.to_string(), result["isError"].as_bool() == Some(true)))
}

/// Runs a SELECT with query_database, and prints the column names and each
/// row. The reply repeats the query, with the LIMIT that the server adds,
/// and then has a line such as "Results (3 rows):", a header line, and one
/// line for each row, with the values separated by tabs. The server writes
/// a tab or a newline inside a value as \t or \n, and a NULL as an empty
/// string.
fn select_cheap_products(client: &Client) -> Result<(), BoxError> {
    let (text, failed) = call_tool(
        client,
        "query_database",
        json!({
            "query": "SELECT name, price FROM cookbook_mcp.products \
                      WHERE price < 100 ORDER BY price",
        }),
    )?;
    if failed {
        return Err(format!("query_database failed: {text}").into());
    }
    let mut lines = text.split('\n').skip_while(|line| !line.starts_with("Results "));
    let (Some(_), Some(header)) = (lines.next(), lines.next()) else {
        return Err(format!("Unexpected reply: {}", shorten(&text)).into());
    };
    println!("Columns: {}", header.replace('\t', ", "));
    for line in lines.filter(|line| !line.is_empty()) {
        println!("Row: {}", line.replace('\t', ", "));
    }
    Ok(())
}

/// Tries to add a row. query_database runs every query in a read-only
/// transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
/// 25006, and the tool returns the error with isError set to true. The
/// INSERT leaves out id, which the table requires, so that even a server
/// that allows writes would refuse it rather than save a row. That is
/// why the function checks for the read-only error itself rather than
/// for any error, and fails if the write succeeds or fails for another
/// reason. It prints only the last line of the error, which holds
/// PostgreSQL's message, because some errors start with the query.
fn try_insert(client: &Client) -> Result<(), BoxError> {
    let (text, failed) = call_tool(
        client,
        "query_database",
        json!({
            "query": "INSERT INTO cookbook_mcp.products \
                      (name, category, price, in_stock, description) \
                      VALUES ('Acme Test Widget', 'Testing', 1.00, true, \
                      'A made-up product that is never saved')",
        }),
    )?;
    let error_line = text.trim().lines().last().unwrap_or("");
    if !failed {
        return Err("The server accepted a write".into());
    }
    if !text.contains("SQLSTATE 25006") {
        return Err(format!("The INSERT failed for another reason: {error_line}").into());
    }
    println!("INSERT rejected: {error_line}");
    Ok(())
}

/// Makes the two calls. The ? operator returns any error to the caller.
fn run() -> Result<(), BoxError> {
    if token().is_empty() {
        return Err("MCP_TOKEN must be set".into());
    }
    let client = Client::builder().timeout(TIMEOUT).build()?;
    select_cheap_products(&client)?;
    try_insert(&client)
}

/// Reports any error returned by run() and exits with a failure status.
/// The top-level error is often generic ("error sending request"), so the
/// chain of underlying causes is printed too.
fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("MCP Server error: {e}");
            let mut cause = e.source();
            while let Some(c) = cause {
                eprintln!("  caused by: {c}");
                cause = c.source();
            }
            ExitCode::FAILURE
        }
    }
}
# Ellie's Postgres Cookbook
# Running read-only queries through the pgEdge Postgres MCP Server, using
# Net::HTTP.
#
# These examples are for pgEdge Postgres MCP Server 1.1.0.
#
# Copyright (c) 2026, pgEdge, Inc.
# This code is released under the PostgreSQL License.

require "json"
require "net/http"
require "uri"

# Returns the value of an environment variable, or a fallback if the
# variable is unset or empty.
def env_or(name, fallback)
  value = ENV[name]
  value.nil? || value.empty? ? fallback : value
end

BASE_URL = env_or("MCP_URL", "http://localhost:8080").sub(%r{/+\z}, "")
TOKEN = env_or("MCP_TOKEN", "")

TIMEOUT_SECONDS = 30

# Raised when the MCP Server rejects a request or replies oddly.
class McpError < StandardError; end

# Parses a reply as a JSON object, or returns nil if it is not one.
def parse_object(text)
  body = JSON.parse(text.to_s)
  body.is_a?(Hash) ? body : nil
rescue JSON::ParserError
  nil
end

# Calls a tool with tools/call and returns the text of its reply and
# whether the tool failed, which it reports by setting isError. The server
# answers a JSON-RPC error with HTTP status 200, so the method checks the
# reply for an error member as well as the HTTP status.
def call_tool(http, name, arguments)
  request = Net::HTTP::Post.new(URI("#{BASE_URL}/mcp/v1"))
  request["Authorization"] = "Bearer #{TOKEN}"
  request["Content-Type"] = "application/json"
  request.body = JSON.generate({ jsonrpc: "2.0", id: 1, method: "tools/call",
                                 params: { name: name, arguments: arguments } })
  response = http.request(request)
  unless response.code == "200"
    raise McpError, "#{name} failed with HTTP #{response.code}: #{response.body.to_s[0, 200]}"
  end

  reply = parse_object(response.body)
  unless reply && (reply.key?("result") || reply.key?("error"))
    raise McpError, "#{name} failed: unexpected response: #{response.body.to_s[0, 200]}"
  end
  raise McpError, "#{name} failed: #{reply["error"]["message"]}" if reply.key?("error")

  result = reply["result"]
  text = result.is_a?(Hash) ? result.dig("content", 0, "text") : nil
  raise McpError, "#{name} failed: unexpected result: #{response.body.to_s[0, 200]}" unless text.is_a?(String)

  [text, result["isError"] == true]
end

# Runs a SELECT with query_database, and prints the column names and each
# row. The reply repeats the query, with the LIMIT that the server adds,
# and then has a line such as "Results (3 rows):", a header line, and one
# line for each row, with the values separated by tabs. The server writes a
# tab or a newline inside a value as \t or \n, and a NULL as an empty
# string.
def select_cheap_products(http)
  text, failed = call_tool(http, "query_database", {
                             query: "SELECT name, price FROM cookbook_mcp.products " \
                                    "WHERE price < 100 ORDER BY price"
                           })
  raise McpError, "query_database failed: #{text}" if failed

  lines = text.split("\n")
  start = lines.index { |line| line.start_with?("Results ") }
  raise McpError, "Unexpected reply: #{text[0, 200]}" if start.nil? || start + 1 >= lines.length

  puts "Columns: #{lines[start + 1].gsub("\t", ", ")}"
  lines[(start + 2)..].reject(&:empty?).each do |line|
    puts "Row: #{line.gsub("\t", ", ")}"
  end
end

# Tries to add a row. query_database runs every query in a read-only
# transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
# 25006, and the tool returns the error with isError set to true. The
# INSERT leaves out id, which the table requires, so that even a server
# that allows writes would refuse it rather than save a row. That is
# why the method checks for the read-only error itself rather than
# for any error, and fails if the write succeeds or fails for another
# reason. It prints only the last line of the error, which holds
# PostgreSQL's message, because some errors start with the query.
def try_insert(http)
  text, failed = call_tool(http, "query_database", {
                             query: "INSERT INTO cookbook_mcp.products " \
                                    "(name, category, price, in_stock, description) " \
                                    "VALUES ('Acme Test Widget', 'Testing', 1.00, true, " \
                                    "'A made-up product that is never saved')"
                           })
  error_line = text.strip.split("\n").last.to_s
  raise McpError, "The server accepted a write" unless failed
  unless text.include?("SQLSTATE 25006")
    raise McpError, "The INSERT failed for another reason: #{error_line}"
  end

  puts "INSERT rejected: #{error_line}"
end

if TOKEN.empty?
  warn "MCP_TOKEN must be set"
  exit 1
end

begin
  uri = URI(BASE_URL)
  # start opens a connection, and closes it when the block exits, whether
  # or not an error occurred.
  Net::HTTP.start(
    uri.host, uri.port,
    use_ssl: uri.scheme == "https",
    open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS
  ) do |http|
    select_cheap_products(http)
    try_insert(http)
  end
# SystemCallError covers a refused connection, Timeout::Error covers the
# connection and read timeouts, and SocketError covers an unknown host.
rescue McpError, SystemCallError, Timeout::Error, SocketError, IOError => e
  warn "MCP Server error: #{e.message}"
  exit 1
end
#!/usr/bin/env bash
#
# Ellie's Postgres Cookbook
# Running read-only queries through the pgEdge Postgres MCP Server, using
# curl and jq.
#
# These examples are for pgEdge Postgres MCP Server 1.1.0.
#
# Copyright (c) 2026, pgEdge, Inc.
# This code is released under the PostgreSQL License.

# Stop at the first failed command or unset variable, and treat a failure
# anywhere in a pipeline as a failure of the whole pipeline.
set -euo pipefail

# The script needs curl to make the requests and jq to build the requests
# and read the replies.
for tool in curl jq; do
    command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; }
done

# Read the settings from the environment. The :- and :? forms, unlike - and
# ?, treat an empty variable as unset.
MCP_URL="${MCP_URL:-http://localhost:8080}"
MCP_URL="${MCP_URL%/}"
: "${MCP_TOKEN:?MCP_TOKEN must be set}"

TIMEOUT_SECONDS=30

# Calls a tool with tools/call and prints its result, which holds the text
# of the reply in content[0].text and, if the tool failed, isError set to
# true: call_tool NAME ARGUMENTS_JSON. The server answers a JSON-RPC error
# with HTTP status 200, so the function checks the reply for an error
# member as well as the HTTP status. The token goes to curl in a header
# file that bash supplies through a pipe, so that it never appears in the
# arguments of the curl process, which other users can read in the process
# list.
call_tool() {
    local name="$1" request reply status body
    request=$(jq -nc --arg name "$name" --argjson arguments "$2" \
        '{jsonrpc: "2.0", id: 1, method: "tools/call",
          params: {name: $name, arguments: $arguments}}')
    reply=$(curl --silent --show-error \
        --connect-timeout 10 --max-time "$TIMEOUT_SECONDS" \
        --header @<(printf 'Authorization: Bearer %s\n' "$MCP_TOKEN") \
        --header 'Content-Type: application/json' \
        --write-out '\n%{http_code}' --data-binary "$request" \
        "$MCP_URL/mcp/v1") || return 1
    # --write-out puts the HTTP status on a line of its own at the end.
    status="${reply##*$'\n'}"
    body="${reply%$'\n'*}"
    if [[ "$status" != 200 ]]; then
        echo "$name failed with HTTP $status: ${body:0:200}" >&2
        return 1
    fi
    if ! jq -e '.result.content[0].text or has("error")' >/dev/null 2>&1 \
        <<<"$body"; then
        echo "$name failed: unexpected response: ${body:0:200}" >&2
        return 1
    fi
    if jq -e 'has("error")' >/dev/null <<<"$body"; then
        echo "$name failed: $(jq -r '.error.message' <<<"$body")" >&2
        return 1
    fi
    jq -c '.result' <<<"$body"
}

# Runs a SELECT with query_database, and prints the column names and each
# row. The reply repeats the query, with the LIMIT that the server adds,
# and then has a line such as "Results (3 rows):", a header line, and one
# line for each row, with the values separated by tabs. The server writes
# a tab or a newline inside a value as \t or \n, and a NULL as an empty
# string.
select_cheap_products() {
    local query result
    query="SELECT name, price FROM cookbook_mcp.products
           WHERE price < 100 ORDER BY price"
    result=$(call_tool query_database \
        "$(jq -nc --arg query "$query" '{query: $query}')") || return 1
    if jq -e '.isError == true' >/dev/null <<<"$result"; then
        echo "query_database failed: $(jq -r '.content[0].text' <<<"$result")" >&2
        return 1
    fi
    jq -r '
        .content[0].text | split("\n") as $lines
        | ($lines | map(startswith("Results ")) | index(true)) as $start
        | if $start == null or $start + 1 >= ($lines | length)
          then error("The reply had no results") else . end
        | "Columns: \($lines[$start + 1] | split("\t") | join(", "))",
          ($lines[$start + 2:][] | select(. != "")
           | "Row: \(split("\t") | join(", "))")' <<<"$result" || {
        echo "Unexpected reply: $(jq -r '.content[0].text' <<<"$result" | head -c 200)" >&2
        return 1
    }
}

# Tries to add a row. query_database runs every query in a read-only
# transaction, so PostgreSQL itself refuses the INSERT with SQLSTATE
# 25006, and the tool returns the error with isError set to true. The
# INSERT leaves out id, which the table requires, so that even a server
# that allows writes would refuse it rather than save a row. That is
# why the script checks for the read-only error itself rather than
# for any error, and fails if the write succeeds or fails for another
# reason. It prints only the last line of the error, which holds
# PostgreSQL's message, because some errors start with the query.
try_insert() {
    local query result text error_line
    query="INSERT INTO cookbook_mcp.products
               (name, category, price, in_stock, description)
           VALUES ('Acme Test Widget', 'Testing', 1.00, true,
                   'A made-up product that is never saved')"
    result=$(call_tool query_database \
        "$(jq -nc --arg query "$query" '{query: $query}')") || return 1
    if ! jq -e '.isError == true' >/dev/null <<<"$result"; then
        echo "The server accepted a write" >&2
        return 1
    fi
    text=$(jq -r '.content[0].text' <<<"$result")
    error_line="${text##*$'\n'}"
    if [[ "$text" != *"SQLSTATE 25006"* ]]; then
        echo "The INSERT failed for another reason: $error_line" >&2
        return 1
    fi
    echo "INSERT rejected: $error_line"
}

main() {
    select_cheap_products
    try_insert
}

main

Next Steps

The following resources provide more information about the topics in this recipe: