# Using the Functions Framework

## Overview

The Speakeasy Functions Framework is a lightweight way to build MCP tools in TypeScript, published as the [`@gram-ai/functions`](https://github.com/speakeasy-api/gram/tree/main/ts-framework/functions) package. It handles the MCP implementation so a function only has to contain its tool logic. A function bundle's [manifest and entrypoint](/docs/ai-control-plane/mcp-gateway/building-servers/functions#bundle-structure) can be written by hand, but the framework handles manifest generation, bundling, input validation, and packaging automatically at build time. Deployed functions appear on the **Sources** tab of **MCP Gateway > MCP** in the dashboard.

The project scaffolder offers the framework as its first choice:

![Choosing the Functions framework in the project scaffolder](/assets/docs/gram/img/functions/npm_gram-framework.png)

To skip the prompt and scaffold straight onto the framework, pass the `gram` template. With pnpm:

```bash
pnpm create @gram-ai/function@latest --template gram
```

## Function structure

Every Function follows this basic structure:

```typescript filename="gram.ts"
import { Gram } from "@gram-ai/functions";
import * as z from "zod/mini";

const gram = new Gram().tool({
  name: "add",
  description: "Add two numbers together",
  inputSchema: { a: z.number(), b: z.number() },
  async execute(ctx, input) {
    return ctx.json({ sum: input.a + input.b });
  },
});

export default gram;
```

## Tool definition

Each tool requires the following properties:

- **`name`:** A unique identifier for the tool
- **`description`(optional):** A human-readable explanation of what the tool does
- **`inputSchema`:** A Zod schema defining the expected input parameters
- **`annotations`(optional):** Behavior hints for AI models and clients (see [Tool annotations](/docs/ai-control-plane/mcp-gateway/building-servers/functions/tool-annotations))
- **`execute`:** An async function that implements the tool logic

A tool that computes a result from validated input:

```typescript filename="src/functions.ts"
import { Gram } from "@gram-ai/functions";
import * as z from "zod/mini";

const gram = new Gram().tool({
  name: "calculate_discount",
  description: "Calculate the final price after applying a discount percentage",
  inputSchema: {
    originalPrice: z.number().positive(),
    discountPercent: z.number().min(0).max(100),
  },
  async execute(ctx, input) {
    const discount = input.originalPrice * (input.discountPercent / 100);
    const finalPrice = input.originalPrice - discount;

    return ctx.json({
      originalPrice: input.originalPrice,
      discount: discount,
      finalPrice: finalPrice,
    });
  },
});

export default gram;
```

## Context object

The `execute` function receives a context object with several helper methods for handling responses and accessing configuration:

### Response methods

- **`ctx.json(data)`:** Returns a JSON response
- **`ctx.text(data)`:** Returns a plain text response
- **`ctx.html(data)`:** Returns an HTML response
- **`ctx.fail(data, options?)`:** Throws an error response

```typescript
const gram = new Gram().tool({
  name: "format_data",
  inputSchema: { format: z.enum(["json", "text", "html"]), data: z.string() },
  async execute(ctx, input) {
    if (input.format === "json") {
      return ctx.json({ data: input.data });
    } else if (input.format === "text") {
      return ctx.text(input.data);
    } else {
      return ctx.html(`<div>${input.data}</div>`);
    }
  },
});
```

### Additional context properties

- **`ctx.signal`:** An `AbortSignal` for handling cancellation
- **`ctx.env`:** Access to parsed environment variables

```typescript
const gram = new Gram().tool({
  name: "long_running_task",
  inputSchema: { url: z.string() },
  async execute(ctx, input) {
    try {
      const response = await fetch(input.url, { signal: ctx.signal });
      return ctx.json(await response.json());
    } catch (error) {
      if (error.name === "AbortError") {
        return ctx.fail("Request was cancelled");
      }
      throw error;
    }
  },
});
```

### Caller identity

Tool calls that arrive over MCP carry attribution about the caller:

- **`ctx.clientInfo`:** The name and version of the calling MCP client, when the client reports them
- **`ctx.oauthClientId`:** The OAuth client ID attributed to the caller, when one accompanied the call
- **`ctx.meta`:** The raw `_meta` block sent with the call, for keys the framework does not model yet

Every field is optional. Calls that do not arrive over MCP, such as direct instance invocations and resource reads, leave all three undefined.

```typescript
const gram = new Gram().tool({
  name: "record_action",
  inputSchema: { action: z.string() },
  async execute(ctx, input) {
    console.log("tool call", {
      action: input.action,
      client: ctx.clientInfo?.name,
      clientVersion: ctx.clientInfo?.version,
      oauthClient: ctx.oauthClientId,
    });
    return ctx.json({ recorded: true });
  },
});
```

> **Attribution only**
> Caller identity is metadata attached to the request, and a tool cannot
> distinguish a value the host attached from one the caller supplied. Use these
> fields for logging, metrics, and telling one integration's traffic from
> another's. Never gate access on them. Authorization belongs to whatever
> verifies credentials in front of the tool, such as
> [OAuth](/docs/ai-control-plane/mcp-gateway/building-servers/functions/add-oauth).

## Input validation

The Functions Framework validates inputs against the provided Zod schema by default. For strict validation, it rejects inputs that don't match the schema.

### Lax mode

To allow unvalidated inputs, enable lax mode:

```typescript
const gram = new Gram({ lax: true }).tool({
  name: "flexible_tool",
  inputSchema: { required: z.string() },
  async execute(ctx, input) {
    // input may contain additional properties not in the schema
    return ctx.json({ received: input });
  },
});
```

## Environment variables

Functions can access environment variables directly from `process.env`:

```typescript
const gram = new Gram().tool({
  name: "api_call",
  inputSchema: { endpoint: z.string() },
  async execute(ctx, input) {
    const apiUrl = process.env.API_URL;
    const response = await fetch(`${apiUrl}/${input.endpoint}`);
    return ctx.json(await response.json());
  },
});
```

For more details on configuring and managing environment variables in Functions, see [Configuring environments](/docs/ai-control-plane/mcp-gateway/building-servers/functions/configuring-environments).

## Using the Fetch API

Tools can make requests to downstream APIs and respond with the following result:

```typescript
const gram = new Gram().tool({
  name: "spacex-ships",
  description: "Get the latest SpaceX ship list",
  inputSchema: {},
  async execute(ctx) {
    const response = await fetch("https://api.spacexdata.com/v3/ships");
    return ctx.json(await response.json());
  },
});
```

## Response flexibility

Tools can return responses in multiple formats:

- JSON responses via `ctx.json()`
- Plain text via `ctx.text()`
- HTML content via `ctx.html()`
- Custom Web API response objects with specific headers and status codes

```typescript
const gram = new Gram().tool({
  name: "custom_response",
  inputSchema: { code: z.number() },
  async execute(ctx, input) {
    return new Response("Custom response", {
      status: input.code,
      headers: { "X-Custom-Header": "value" },
    });
  },
});
```

## Composability

Instances of `Gram` can be composed together using the `.extend()` method, allowing tool definitions to be split across multiple files and modules. This pattern is similar to Hono's grouping pattern and helps organize larger codebases.

### Basic composition

Split tool definitions into separate modules and combine them:

```typescript filename="train.ts"
import { Gram } from "@gram-ai/functions";
import * as z from "zod/mini";

export const trainGram = new Gram({
  envSchema: {
    TRAIN_API_KEY: z.string().describe("API key for the train service"),
  },
})
  .tool({
    name: "train_book",
    description: "Books a train ticket",
    inputSchema: { destination: z.string(), date: z.string() },
    async execute(ctx, input) {
      const apiKey = ctx.env.TRAIN_API_KEY;
      // Implementation here
      return ctx.json({ booked: true });
    },
  })
  .tool({
    name: "train_status",
    description: "Gets the status of a train",
    inputSchema: { trainId: z.string() },
    async execute(ctx, input) {
      // Implementation here
      return ctx.json({ status: "on time" });
    },
  });
```

```typescript filename="flight.ts"
import { Gram } from "@gram-ai/functions";
import * as z from "zod/mini";

export const flightGram = new Gram({
  envSchema: {
    FLIGHT_API_KEY: z.string().describe("API key for the flight service"),
  },
})
  .tool({
    name: "flight_book",
    description: "Books a flight ticket",
    inputSchema: { destination: z.string(), date: z.string() },
    async execute(ctx, input) {
      const apiKey = ctx.env.FLIGHT_API_KEY;
      // Implementation here
      return ctx.json({ booked: true });
    },
  })
  .tool({
    name: "flight_status",
    description: "Gets the status of a flight",
    inputSchema: { flightNumber: z.string() },
    async execute(ctx, input) {
      // Implementation here
      return ctx.json({ status: "departed" });
    },
  });
```

```typescript filename="gram.ts"
import { Gram } from "@gram-ai/functions";
import { trainGram } from "./train";
import { flightGram } from "./flight";

const gram = new Gram().extend(trainGram).extend(flightGram);

export default gram;
```

### Environment schema merging

When composing `Gram` instances, environment schemas are automatically merged. Each module can define its own environment variables, and the final composed instance will validate all required variables:

```typescript
// Each module defines its own environment requirements
const weatherGram = new Gram({
  envSchema: {
    WEATHER_API_KEY: z.string(),
  },
}).tool({
  name: "get_weather",
  inputSchema: { city: z.string() },
  async execute(ctx, input) {
    // Access environment variable from this module
    const apiKey = ctx.env.WEATHER_API_KEY;
    return ctx.json({ temperature: 72 });
  },
});

const newsGram = new Gram({
  envSchema: {
    NEWS_API_KEY: z.string(),
  },
}).tool({
  name: "get_news",
  inputSchema: { topic: z.string() },
  async execute(ctx, input) {
    // Access environment variable from this module
    const apiKey = ctx.env.NEWS_API_KEY;
    return ctx.json({ articles: [] });
  },
});

// Composed instance requires both environment variables
const gram = new Gram().extend(weatherGram).extend(newsGram);
// Both WEATHER_API_KEY and NEWS_API_KEY must be provided
```

### Benefits of composition

Composing `Gram` instances provides several advantages:

- **Modularity**: Organize related tools into separate files
- **Reusability**: Share tool definitions across different `Gram` instances
- **Maintainability**: Easier to manage large codebases with many tools
- **Team collaboration**: Different team members can work on separate modules

## Next steps

- [Build and deploy](/docs/ai-control-plane/mcp-gateway/building-servers/functions/build-deploy) Functions.
