Is Attio MCP server safe?
- Node.js shell/command execution
- Possible command injection (exec with dynamic command)
- Remote pipe-to-shell execution
attio-mcp is an AI npm_package analyzed by SkillTotal's deterministic static scanner. The scan found no malicious indicators, though 12 risky constructs are reported for review. It can: delegated authentication, filesystem read, filesystem write, install time execution, mcp tools detected, network egress and shell execution — capabilities are what the code can do, not a verdict on intent. Risk score 40/100 (medium).
attio-mcp 1.6.1
Automated static-analysis result. It can contain false positives and false negatives, and is not a claim about the intent of Attio MCP server's authors. Report a false positive.
Behavioral traits
How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.
Findings (12)
The code builds an OS command out of values that can change at runtime, then runs it through a shell.
const count = execSync(`find . -name "*${ext}" -not -path "./node_modules/*" -not -path "./dist/*" | wc -l`, { encoding: 'utf8' }).trim();const result = execSync(`npm run test:${suite} 2>/dev/null || echo "FAILED"`, { encoding: 'utf8' });const diffOutput = execSync(
`git diff --name-status ${baseBranch}...HEAD`,Why it matters: If any of those values come from untrusted input, an attacker can run their own commands on the machine.
Fix: Use execFile/spawn with an argument array instead of exec; never build a shell command string from external input.
package.json runs scripts automatically when the package is installed.
"postinstall": "[ -d .git ] && husky || echo 'Skipping git hooks setup (not a git repository)'",
Why it matters: Install scripts are a favorite supply-chain foothold — they execute on every machine that installs the package.
Fix: Inspect the hook command. Install-time scripts are a common supply chain execution vector; ensure they do nothing beyond a documented build step.
The component can run operating-system commands or spawn processes.
const { execSync } = require('child_process');const output = execSync(command, {execSync(command, {return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();const { execFileSync } = require('node:child_process');import { execFileSync } from 'node:child_process';while ((match = pattern.exec(source)) !== null) {while ((match = regex.exec(source)) !== null) {while ((match = regex.exec(source)) !== null) {import { execSync } from 'child_process';const result = execSync(
import { spawn } from 'child_process';const child = spawn('node', ['dist/index.js'], {const { execSync } = require('child_process');const count = execSync(`find . -name "*${ext}" -not -path "./node_modules/*" -not -path "./dist/*" | wc -l`, { encoding: 'utf8' }).trim();const srcLines = execSync('find src -name "*.ts" | xargs wc -l | tail -1', { encoding: 'utf8' }).trim().split(/\\s+/)[0];const testLines = execSync('find test -name "*.ts" | xargs wc -l | tail -1', { encoding: 'utf8' }).trim().split(/\\s+/)[0];const result = execSync(`npm run test:${suite} 2>/dev/null || echo "FAILED"`, { encoding: 'utf8' });const lintResult = execSync('npm run lint:check 2>&1 || echo "LINT_FAILED"', { encoding: 'utf8' });const tscResult = execSync('npm run typecheck 2>&1 || echo "TSC_FAILED"', { encoding: 'utf8' });const formatResult = execSync('npm run check:format 2>&1 || echo "FORMAT_FAILED"', { encoding: 'utf8' });const commits = execSync('git log --oneline -10', { encoding: 'utf8' })const contributors = execSync('git shortlog -sn --all', { encoding: 'utf8' })const branches = execSync('git branch -r', { encoding: 'utf8' })return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.
Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; prefer execFile with an argument array.
A remotely fetched payload is piped straight into a shell (e.g. `curl … | bash`). The component runs code downloaded at runtime, which is unreviewable and a common second-stage delivery vector. (5 occurrence(s) shown as evidence).
curl -LsSf https://astral.sh/uv/install.sh | sh
Fix: Download to a file, inspect it, and run a pinned/verified copy instead of piping a network response directly into a shell.
The component reads files from disk.
const raw = fs.readFileSync(file, 'utf8');
content = fs.readFileSync(envPath, 'utf-8');
const content = fs.readFileSync(envPath, 'utf-8');
return JSON.parse(fs.readFileSync(this.budgetsPath, 'utf8'));
const latest = JSON.parse(fs.readFileSync(
const baseline = JSON.parse(fs.readFileSync(
const content = fs.readFileSync(fullPath, 'utf8');
const content = fs.readFileSync(fullPath, 'utf8');
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));stats.coverage = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));stats.budgets = JSON.parse(fs.readFileSync(budgetsPath, 'utf8'));
stats.latest = JSON.parse(fs.readFileSync(
return JSON.parse(fs.readFileSync(
let content = fs.readFileSync(filePath, 'utf8');
let content = fs.readFileSync(filePath, 'utf8');
let content = fs.readFileSync(filePath, 'utf8');
let content = fs.readFileSync(filePath, 'utf8');
const changelogContent = fs.readFileSync(changelogPath, 'utf8');
const indexContent = fs.readFileSync('./src/objects/people/index.ts', 'utf8');const envContent = fs.readFileSync(envPath, 'utf8');
fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')
const templateSource = await fs.readFile(templatePath, 'utf8');
const content = fs.readFileSync(filePath, 'utf8');
fs.readFileSync(CONFIG_PATHS.user, 'utf8')
fs.readFileSync(CONFIG_PATHS.user, 'utf8')
Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.
Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.
The component writes or deletes files on disk.
fs.writeFileSync(file, JSON.stringify(evaluation, null, 2), 'utf8');
fs.writeFileSync(envPath, content.trim() + '\n');
fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
fs.writeFileSync(reportPath, report);
fs.writeFileSync(protectedFile, analysis.protected.join('\\n') + '\\n');fs.writeFileSync(deleteFile, analysis.toDelete.join('\\n') + '\\n');fs.writeFileSync(ambiguousFile, analysis.ambiguous.join('\\n') + '\\n');fs.writeFileSync(reportFile, report);
fs.writeFileSync(filepath, content);
fs.writeFileSync(filepath, content);
fs.unlinkSync(latestPath);
fs.writeFileSync(destination, `${JSON.stringify(data, null, 2)}\n`, 'utf8');fs.writeFileSync(filePath, content);
fs.writeFileSync(filePath, content);
fs.writeFileSync(filePath, content);
fs.writeFileSync(filePath, content);
fs.unlinkSync(sourcePath);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
fs.writeFileSync(htmlReportPath, html);
fs.writeFileSync(path.resolve(process.cwd(), outputPath), releaseNotes);
fs.writeFileSync(reportPath, report);
await fs.writeFile(fullPath, content, 'utf8');
await fs.writeFile(zipPath, zipped);
fs.writeFileSync(
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf8');
Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.
Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.
The component reads files from disk.
with open(log_file, 'r') as f:
with open(log_file, 'r') as f:
with open(log_file, 'r') as f:
with open(history_file, 'r') as f:
with open(config_file, 'r') as f:
return template_path.read_text()
with open(args.workspace_schema_file) as f:
content = skill_md.read_text()
Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.
Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.
The component writes or deletes files on disk.
with open(history_file, 'w') as f:
with open(output_file, 'w') as f:
os.unlink(log_files[0])
(skill_dir / 'SKILL.md').write_text(skill_content)
(skill_dir / 'resources' / 'workflows.md').write_text(workflows_content)
(skill_dir / 'resources' / 'tool-reference.md').write_text(tool_ref_content)
(skill_dir / 'resources' / 'examples.md').write_text(examples_content)
(skill_dir / 'SKILL.md').write_text(skill_md_content)
(skill_dir / 'resources' / 'README.md').write_text(resources_readme)
(skill_dir / 'references' / 'README.md').write_text(references_readme)
Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.
Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.
package.json has a 'prepare' script (runs on git/local installs and before publishing).
"prepare": "bun run build",
Why it matters: Usually a build step, but confirm it doesn't fetch or run remote code.
Fix: Usually a legitimate build step; confirm it only builds and does not fetch or execute remote code.
The component makes outbound network requests.
const response = await fetch(url, {const req = https.request(options, (res) => {import axios from 'axios';
const http = axios.create({console.log('🧪 Testing pure axios against Attio API...');const probe = await http.get('/objects/companies');import axios from 'axios';
const http = axios.create({const probe = await http.get('/objects/companies');import { AxiosInstance } from 'axios';import { AxiosInstance } from 'axios';import { AxiosInstance } from 'axios';import { AxiosInstance } from 'axios';import { AxiosInstance } from 'axios';import { AxiosInstance } from 'axios';import { AxiosInstance } from 'axios';import axios from 'axios';
const client = axios.create({import axios from 'axios';
const response = await axios.get(url, {const optionsResponse = await axios.get(optionsUrl, {import axios from 'axios';
Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.
Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.
An OAuth 2.0 / OpenID Connect delegated-authentication flow was detected (authorization-code / refresh-token / token-exchange grant, an OIDC authorize/discovery endpoint or id_token, or a delegation library). Tools authenticate with the end user's delegated, scoped credentials rather than a long-lived embedded service credential. (8 occurrence(s) shown as evidence).
grant_type: 'authorization_code',
grant_type: 'refresh_token',
Fix: Delegated auth is a lower-blast-radius execution context than an embedded static credential. Confirm the requested scopes are minimal and that tokens are never logged or forwarded off-host.
An MCP tool surface (manifest or tool definitions) was found.
* const server = new Server();
const mcpServer = new Server(
Why it matters: Just context — review which tools it offers and their permissions.
Fix: Review the declared MCP tools and their permissions.
Check your own component
Run the same evidence-backed scan on any MCP server, agent skill, or package.
Scan your own componentHow we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →