9 min read Rocky Elsalaymeh
Zero native integrations. By design.
Integration count is the vanity metric of agent platforms. Every connector is a credential someone holds and code someone must patch. I moved that decision to the operator, and I will show you where the boundary leaks.
Connector counts sell software. They also count the credentials that someone else holds for you.
In August 2025, Google Cloud’s threat intelligence blog reported that the actor targeted Salesforce customer instances through compromised OAuth tokens associated with the Salesloft Drift third-party application. The token lived in a third party’s house.
Team-X, an open-source, local-first desktop app for running AI-agent organizations, ships no first-party Slack, GitHub, Jira, Notion, or Discord integration and never asks for an OAuth grant. Every outside capability arrives through a Model Context Protocol server that the operator installs. One host in the main process routes every call, and each role’s allow and deny lists filter it.
That is a decision, not an omission. It is also not a free lunch, and this post is the receipt for both halves.
Why is an integration count the wrong number?
Because each native integration moves three decisions from you to the vendor: whose credential is stored, how wide its scope is, and who patches the glue code.
OWASP’s 2025 list for LLM applications names the failure shape. Under Excessive Permissions it lists the case where “An LLM extension has permissions on downstream systems that are not needed for the intended operation of the application.” A scope fixed by the vendor becomes the operator’s problem whenever it is wider than the task.
A protocol boundary changes who picks. The operator installs a server, the operator decides which binary runs, and the operator decides which employee may call which tool. Team-X does not hold a Slack token because Team-X never has a reason to.
Anthropic introduced MCP in November 2024 as “an open standard that enables developers to build secure, two-way connections between their data sources and AI-powered tools.” The word “secure” in that sentence is a goal, not a guarantee. The rest of this post is about the gap.
Is it true that Team-X has no first-party integrations?
Yes at the pinned commit, and I checked the code rather than the docs.
I searched every package manifest and source file in apps and packages at commit 62be216 for slack, octokit, jira, notion, and discord. The only hits were binary GGUF test fixtures, which are model files. I also searched for OAuth as a whole word and found nothing. The desktop app’s dependency list contains one integration-shaped package: @modelcontextprotocol/sdk, declared as ^1.0.4 and resolved to 1.29.0 in the lockfile.
The integration guide at that commit says the same thing in plain words: “Team-X itself ships no first-party SaaS connectors.” I trust the grep more than the sentence. They agree.
There are two extension surfaces. MCP servers supply tools. Skills supply instructions. Both are installed by the operator, and neither involves a Team-X-held credential. In August the audit deleted unreachable install dialogs that fabricated their manifest preview, and the CHANGELOG entry records that the real, security-hardened manifest loader sat unused behind them. The loader described below is that real one.
How does the single MCP host work?
There is one host, created once in the main process, holding one connection per server in a single map. Agents never spawn their own clients.
The file header calls it a “singleton MCP connection pool and tool call router”. It is created at index.ts:937 with the allowlist file and the user data directory wired in, and its tool lister scopes servers by company: a server with no company is global, any other server is visible only to its own company.
The host supports exactly two transports: stdio and sse. That is a type in the host file, and the only transport imports are the SDK’s stdio and SSE clients.
This matters because the spec has moved. The 2025-06-18 revision of the MCP transports page says “The protocol currently defines two standard transport mechanisms for client-server communication:” and the two are stdio and Streamable HTTP. It marks the older HTTP+SSE transport as replaced. At the pinned commit, Team-X speaks the older one for remote servers and has no Streamable HTTP path in the host.
The SSE path also reads one field from the server config: a URL. There is no header or token field in the code that builds it. A remote server that needs a bearer token cannot be authenticated by this host today. The integration guide shows a headers block for SSE; the code does not read it, so I am not repeating that example.
How are tools_allowed and tools_denied enforced?
Twice, in the same order every time, with denial winning.
Both fields are declared on every role spec as string arrays defaulting to empty. Here is the full excerpt from the one role at that commit that sets them, the CEO spec:
tools_allowed: [browse, email, calendar, context7, episodic-memory]
tools_denied: [shell, filesystem_write]
The first check happens when Team-X builds the tool list for a run. The code comment says it plainly: “Pre-filter at definition time so the model never sees denied tools.” The lists come from an authority resolver that layers role defaults, extension grants, company and employee grants, and platform hard-denies, with the higher layer winning.
The second check is inside the host, at call time, and it does not trust the first:
// 1. Check tools_denied first (explicit denial wins)
if (args.toolsDenied.includes(args.toolName)) {
Then, if the allowlist is non-empty and the tool is not in it, the call is refused. In both cases the host emits an authority.violation event, writes the attempt to the tool call log with status denied, and returns an error naming the role restriction. The server is never contacted. The order is deny, then allowlist, then connection, then execution (lines 336-360).
Now the part a cheerleader would skip.
An empty allowlist means everything is allowed. A test pins it: allows a tool when tools_allowed is empty (no allowlist). Of the 57 role files at that commit, 56 declare tools_allowed: []. So for 56 roles the practical control is the deny list and the choice of which servers exist, not an allowlist. The field-by-field audit of a role spec is in A role is not a prompt.
The match is on the tool name the server advertises, and the server lookup returns the first connected server offering that name. Two servers with the same tool name collide. The five names in the CEO allowlist read like capability labels, and I have not shown that any server advertises tools with those names. Treat that file as unproven against a live server.
What does mcp-security.ts check, and what does it miss?
It governs the process boundary: which binary starts, with which environment, in which directory. It does not read what the server says.
The checks run in order before any stdio server spawns. The command must be an absolute path, because bare names are refused as PATH lookups an attacker could poison. It must appear in mcp-allowlist.json, which is created empty on first run, and an empty file means nothing spawns. If an entry pins a sha256, the file on disk must match it. The child gets only a short list of environment keys instead of the parent environment, and its working directory is pinned to a per-server folder under the user data directory.
| Risk | Source | What Team-X does at the pinned commit | Gap |
|---|---|---|---|
| Unknown binary spawned | Operator config | Absolute path, allowlist, optional sha256 | Hash is optional per entry |
| Parent secrets inherited | Audit finding in the file header | Env reduced to a short pass-through list | Operator-supplied env is merged in unfiltered |
| Over-broad tool access | OWASP LLM06 | Per-role deny and allow, checked twice | Empty allowlist permits all |
| Poisoned tool description | Invariant Labs | None | Descriptions reach the model unchanged |
| Hostile tool result | MCP spec guidance | Output stored, first 8,192 characters | No scanning before the model sees it |
| Vulnerable client package | JFrog, CVE-2025-6514 | SDK used directly; no mcp-remote dependency | Servers the operator runs bring their own |
The spec’s client guidance sets a higher bar than the role lists. It says clients should “Show tool inputs to the user before calling the server, to avoid malicious or accidental data exfiltration”. The host’s callTool has no confirmation step; the role lists are the only gate.
Take the poisoning row seriously. Invariant Labs defined the attack in April 2025: “A Tool Poisoning Attack occurs when malicious instructions are embedded within MCP tool descriptions that are invisible to users but visible to AI models.” Team-X forwards each tool’s description to the model as written. The deny list cannot help if the poisoned tool is one the role is allowed to call.
The client-package risk is real too. JFrog reported that in mcp-remote, “The vulnerability allows attackers to trigger arbitrary OS command execution on the machine running mcp-remote when it initiates a connection to an untrusted MCP server”. Team-X does not depend on that package. That is a statement about my dependency list, not about the servers you choose to run.
There is also a path that skips the gates. The connection test handler builds a stdio transport straight from the pasted config, with no allowlist check. It exists to answer “does this server start,” and it still starts it. I count that as a hole in the boundary.
Even the seeded templates tell the story. Two global templates ship disabled, Context7 and Supabase, and both use the bare command npx. The stdio gate refuses bare names, so they cannot connect until the operator replaces that with an absolute path and allowlists it. The effect is friction, and the arguments are also unpinned: both arguments request @latest.
Where do secrets and skills fit?
Secrets for MCP servers sit in the server config, not in the keychain. Skills are text, and a GitHub source is only partly pinned.
For stdio servers, the operator’s env block is part of the config JSON stored in the local mcp_servers table. Provider API keys take a different route; the schema notes they live in the OS keychain. I did not find the same treatment for MCP server environment values. If your server needs a token, it sits in that row. Team-X still never holds a token for a third-party service on its own account, because it has none.
Skills install from a local folder, a GitHub URL, or any public HTTPS URL. A skill is a manifest plus prompt files. Team-X copies the files into a snapshot directory and appends their text to the employee’s system prompt under an “Installed Skills” heading. It does not run code from a skill.
For GitHub, the installer resolves the ref to a commit and records commitSha in the extension’s metadata. Then it fetches each file using the ref name, not the SHA. If you hand it a URL containing a commit hash, you get that commit. If you hand it a branch or no ref, you get whatever the branch holds at that moment, and the recorded SHA is a receipt, not a pin. After install, the snapshot does not change.
What should you do this week?
Treat the boundary as yours to maintain.
- Keep
mcp-allowlist.jsonsmall, and add a sha256 to every entry whose binary does not auto-update. - Give every role that matters a real
tools_allowedlist, and verify each name against what the server advertises. An empty list is not a policy. - Read the tool descriptions of every server you install before you enable it. Descriptions are model input.
- Install skills from a URL containing a commit hash, never a bare branch.
- Put tokens in the narrowest scope the server supports, and expect them to sit in the local config row.
The source and tests are in the Team-X repository. The role specs that carry the two lists are browsable at /roles/.
Frequently asked questions
Does Team-X have a Slack or GitHub integration?
No. Team-X, an open-source, local-first desktop app for running AI-agent organizations, ships no first-party Slack, GitHub, Jira, Notion, or Discord integration. Anything external arrives as an MCP server the operator installs, and Team-X never asks for an OAuth grant on the operator's behalf.
How does Team-X limit which MCP tools an AI employee can call?
Each role spec carries an allow list and a deny list of tool names. Team-X checks them twice: when it builds the tool list shown to the model, and again inside the single MCP host at call time. Denied wins. A denied call returns a denied status and is recorded in the tool call log.
Is Team-X's MCP setup safe against tool poisoning?
Only partly. Team-X refuses to spawn unlisted binaries and scrubs the child environment, but it passes tool descriptions to the model unchanged and does not scan them. Tool poisoning hides instructions in those descriptions, so the operator's choice of server remains the main defense.
A connector count is a liability count. I would rather own one boundary.