-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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 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 |
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 foractionandresource.urifields 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) }],
};
}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();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)
});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,
};
}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[].namein manifest) - Second argument is the URL the secret will be used with
- Returns
undefinedif the secret doesn't exist, isn't approved, or the URL doesn't match scope - Always check the return value before using it
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');// ErrorsLogs are written to stderr and the audit log. They include the manifest name as a prefix.
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.0Actions 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,
};- 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
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 |
- Standard JavaScript (ES2020+)
-
JSON.parse,JSON.stringify -
String,Array,Object,Map,Set,Promise -
Math,Date,RegExp -
encodeURIComponent,decodeURIComponent -
setTimeout,setInterval(limited)
-
require()orimport(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(usectx.fetchinstead)
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}` }],
};
}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 }],
};
}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 });
// ...
}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')}` },
],
};
}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 };
}
}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:
- Create a new versioned file (e.g.,
fetch-data.v2.js) - Update the manifest's
actionURL to point to the new file - Update the
actionHashwith the new file's SHA256 - The old version remains available for other manifests that reference it