Skip to content

Writing Actions

Z-M-Huang edited this page Mar 19, 2026 · 3 revisions

Writing Actions

Action scripts are JavaScript modules that run inside a sandboxed environment. They define what happens when a tool is called. This page covers the execution context API, patterns, and constraints.

Basic Structure

Every action script exports a default async function:

export default async function myAction(input, ctx) {
  // input: validated object matching your tool's inputSchema
  // ctx: execution context (fetch, getSecret, log, manifest)

  return {
    content: [{ type: 'text', text: 'Result here' }],
  };
}

The ctx Object

The execution context provides four capabilities:

Property Type Description
ctx.fetch(url, options?) async function HTTP fetch with SSRF protection
ctx.getSecret(name, url) function Get a secret scoped to a URL
ctx.log(level, message) function Structured logging
ctx.manifest object { name, version } of the manifest

ctx.fetch

Makes HTTP requests with built-in security:

  • All URLs validated for SSRF (private IPs blocked)
  • HTTPS required by default
  • Redirects validated and re-checked
  • Cross-origin redirects strip sensitive headers
  • All requests audit-logged

Note: ctx.fetch() only supports HTTP(S) URLs, even when the action script itself was loaded from a local file path. It cannot read local files. Use local file paths only for action and resource.uri fields in the manifest.

export default async function fetchData(input, ctx) {
  const url = `https://api.example.com/data/${input.id}`;

  const response = await ctx.fetch(url, {
    headers: {
      'Accept': 'application/json',
    },
  });

  if (!response.ok) {
    return {
      content: [{ type: 'text', text: `Request failed: ${response.status} ${response.statusText}` }],
      isError: true,
    };
  }

  // response.text is a PROPERTY (string), not a method
  const body = response.text;

  // response.json() is a SYNCHRONOUS method
  const data = response.json();

  return {
    content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
  };
}

Response Object

The fetch response inside the sandbox is NOT a standard Response. It has these properties:

Property/Method Type Description
response.ok boolean true if status is 200-299
response.status number HTTP status code
response.statusText string HTTP status text
response.text string Response body as string (property, not method)
response.headers object Plain object of response headers
response.json() function Synchronous — parses response.text as JSON
response.error string Error message (only present on fetch failure)

Common mistakes:

// WRONG — response.text is a property, not a method
const body = await response.text();

// CORRECT
const body = response.text;

// WRONG — response.json() is synchronous, no await needed
const data = await response.json();

// CORRECT
const data = response.json();

Fetch Options

Standard fetch options are supported:

const response = await ctx.fetch(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  },
  body: JSON.stringify({ key: 'value' }),
  maxSize: 5 * 1024 * 1024,  // 5MB response limit (default: 10MB)
});

Error Handling

When ctx.fetch encounters a network error (DNS failure, connection refused, SSRF block), it returns an error response:

const response = await ctx.fetch(url);

if (!response.ok) {
  // Check for fetch-level errors
  if (response.error) {
    return {
      content: [{ type: 'text', text: `Network error: ${response.error}` }],
      isError: true,
    };
  }

  // HTTP error
  return {
    content: [{ type: 'text', text: `HTTP ${response.status}: ${response.statusText}` }],
    isError: true,
  };
}

ctx.getSecret

Retrieves a secret value, but only if the URL you're using it with matches the secret's scope.

export default async function callApi(input, ctx) {
  const url = 'https://api.example.com/data';

  // Returns the secret value if url matches the secret's scope
  // Returns undefined if the secret doesn't exist or URL is out of scope
  const apiKey = ctx.getSecret('API_KEY', url);

  const headers = { 'Accept': 'application/json' };
  if (apiKey) {
    headers['Authorization'] = `Bearer ${apiKey}`;
    ctx.log('debug', 'Using API key authentication');
  }

  const response = await ctx.fetch(url, { headers });
  // ...
}

Key points:

  • First argument is the secret name (matches secrets[].name in manifest)
  • Second argument is the URL the secret will be used with
  • Returns undefined if the secret doesn't exist, isn't approved, or the URL doesn't match scope
  • Always check the return value before using it

ctx.log

Structured logging at four levels:

ctx.log('debug', 'Detailed trace info');     // For development
ctx.log('info', 'Processing request');       // Normal operations
ctx.log('warn', 'Rate limit approaching');   // Potential issues
ctx.log('error', 'Failed to parse response');// Errors

Logs are written to stderr and the audit log. They include the manifest name as a prefix.

ctx.manifest

Read-only metadata about the current manifest:

ctx.log('info', `Running in ${ctx.manifest.name} v${ctx.manifest.version}`);
// Output: [my-mcp] Running in my-mcp v1.0.0

Return Format

Actions must return an object with a content array:

// Text content
return {
  content: [{ type: 'text', text: 'Plain text result' }],
};

// Multiple content blocks
return {
  content: [
    { type: 'text', text: '## Results\n\nFound 3 items:' },
    { type: 'text', text: '1. Item A\n2. Item B\n3. Item C' },
  ],
};

// Image content
return {
  content: [{
    type: 'image',
    data: base64EncodedString,
    mimeType: 'image/png',
  }],
};

// Error result
return {
  content: [{ type: 'text', text: 'Something went wrong: details here' }],
  isError: true,
};

Result Size Limits

  • Maximum 100 content items per result (excess items are truncated, remainder dropped)
  • If the total serialized result exceeds 1MB, a single pass truncates text items over 10KB each
  • This is a best-effort reduction — the final result may still exceed 1MB if it contains many medium-sized items or large image data
  • Truncation is automatic and does not cause an error

Sandbox Constraints

Actions run in an isolated-vm sandbox with these limits:

Constraint Default Configurable
Memory 128MB --memory-limit (8MB-1GB)
CPU time 30 seconds Not configurable (hardcoded)
Wall time 60 seconds --timeout
Response size 10MB maxSize in fetch options
Max redirects 5 Not configurable

What's Available

  • Standard JavaScript (ES2020+)
  • JSON.parse, JSON.stringify
  • String, Array, Object, Map, Set, Promise
  • Math, Date, RegExp
  • encodeURIComponent, decodeURIComponent
  • setTimeout, setInterval (limited)

What's NOT Available

  • require() or import (no Node.js modules)
  • fs, path, child_process, net (no Node.js APIs)
  • window, document (no browser APIs)
  • Dynamic code generation via Function() constructor is restricted
  • Direct fetch (use ctx.fetch instead)

Patterns

Fetching and Parsing JSON APIs

export default async function queryApi(input, ctx) {
  const url = `https://api.example.com/search?q=${encodeURIComponent(input.query)}`;

  const token = ctx.getSecret('API_TOKEN', url);
  const headers = { 'Accept': 'application/json' };
  if (token) headers['Authorization'] = `Bearer ${token}`;

  const response = await ctx.fetch(url, { headers });

  if (!response.ok) {
    return {
      content: [{ type: 'text', text: `API error: ${response.status}` }],
      isError: true,
    };
  }

  const data = response.json();
  const formatted = data.results
    .map((r, i) => `${i + 1}. **${r.title}** — ${r.summary}`)
    .join('\n');

  return {
    content: [{ type: 'text', text: `Found ${data.total} results:\n\n${formatted}` }],
  };
}

Fetching and Returning Markdown

export default async function getDocs(input, ctx) {
  const url = `https://raw.githubusercontent.com/owner/repo/main/docs/${input.page}.md`;

  const response = await ctx.fetch(url);

  if (!response.ok) {
    return {
      content: [{ type: 'text', text: `Page not found: ${input.page}` }],
      isError: true,
    };
  }

  return {
    content: [{ type: 'text', text: response.text }],
  };
}

Conditional Authentication

export default async function fetchData(input, ctx) {
  const url = `https://api.github.com/repos/${input.owner}/${input.repo}`;

  const headers = { 'Accept': 'application/vnd.github.v3+json' };

  const token = ctx.getSecret('GITHUB_TOKEN', url);
  if (token) {
    headers['Authorization'] = `token ${token}`;
    ctx.log('debug', 'Authenticated request');
  } else {
    ctx.log('info', 'Unauthenticated request (lower rate limits)');
  }

  const response = await ctx.fetch(url, { headers });
  // ...
}

Multiple Sequential Fetches

export default async function enrichData(input, ctx) {
  // Fetch primary data
  const primaryUrl = `https://api.example.com/items/${input.id}`;
  const primary = await ctx.fetch(primaryUrl);

  if (!primary.ok) {
    return {
      content: [{ type: 'text', text: `Item not found: ${input.id}` }],
      isError: true,
    };
  }

  const item = primary.json();

  // Fetch related data
  const relatedUrl = `https://api.example.com/items/${input.id}/related`;
  const related = await ctx.fetch(relatedUrl);

  const relatedItems = related.ok ? related.json().items : [];

  return {
    content: [
      { type: 'text', text: `## ${item.name}\n\n${item.description}` },
      { type: 'text', text: `### Related (${relatedItems.length})\n\n${relatedItems.map(r => `- ${r.name}`).join('\n')}` },
    ],
  };
}

Error Handling Best Practices

export default async function robustAction(input, ctx) {
  try {
    const response = await ctx.fetch(url);

    if (!response.ok) {
      // Return user-friendly error, not raw status
      if (response.status === 404) {
        return { content: [{ type: 'text', text: 'Resource not found.' }], isError: true };
      }
      if (response.status === 429) {
        return { content: [{ type: 'text', text: 'Rate limited. Please try again later.' }], isError: true };
      }
      return { content: [{ type: 'text', text: `Request failed (${response.status}).` }], isError: true };
    }

    // Validate response structure before using it
    const data = response.json();
    if (!data || !Array.isArray(data.items)) {
      return { content: [{ type: 'text', text: 'Unexpected API response format.' }], isError: true };
    }

    return { content: [{ type: 'text', text: formatResults(data.items) }] };
  } catch (err) {
    ctx.log('error', `Action failed: ${err.message}`);
    return { content: [{ type: 'text', text: 'An unexpected error occurred.' }], isError: true };
  }
}

Versioning Actions

Use version suffixes in action file names:

actions/
  fetch-data.v1.js    # Original version
  fetch-data.v2.js    # Updated with new features

When updating an action:

  1. Create a new versioned file (e.g., fetch-data.v2.js)
  2. Update the manifest's action URL to point to the new file
  3. Update the actionHash with the new file's SHA256
  4. The old version remains available for other manifests that reference it

Clone this wiki locally