Context Bridge
The Context Bridge is Ensemble's MCP server for editors and agents. An agent uses it to read Ensemble: a brief for its task (where the task came from, who asked, the people and their roles, related work), any record and what it links to (the context graph, 03 §5), ranked search, tasks, projects, repositories, skills, deliverables, calendar, people, Needs me, files and their contents, diagrams and plots, and through the read actions everything else the assistant reads. A key made with changes allowed also runs Ensemble's writes (tasks, pages, boards, diagrams, plots, new files, projects, people, deliverables, meeting notes, comments, skills, routines, reminders, desk lists, imports; §5.1), and a key made with apps allowed uses the apps the person connected to Ensemble (§5.2). A key works in its own space and in every other space its account owns (§4).
The server is apps/context-bridge. It calls hub-api's /api/bridge routes over HTTP. It never opens the database or holds model or app keys. The editor runs its own model; Ensemble supplies the records, runs the actions with the assistant's own tools, and calls the apps with the person's connections.
1. Three ways to reach it
| Way | For | How |
|---|---|---|
| Ensemble CLI | Hosted-site users on any OS | Install the CLI, ensemble login, then ensemble mcp setup. Editors run ensemble mcp (stdio), which calls https://api.ensemblework.com/api/bridge/* with the CLI's key and the grants approved at ensemble login (§4, 26) |
| Hosted URL | AI apps, automation clients and editors; nothing to install | POST https://api.ensemblework.com/mcp with an OAuth access token or a static Authorization: Bearer ens_… key, and optionally x-ensemble-space: <space id> (§4). Served by hub-api (src/routes/mcp.ts), stateless Streamable HTTP (26 §5) |
| From source | Developers running this repository | pnpm bridge:build, then the editor launches node apps/context-bridge/dist/index.js (§3) |
All ways use the same Context Bridge and /api/bridge checks. Stdio and ordinary static keys use buildServer (apps/context-bridge/src/server.ts). Hosted OAuth uses buildCatalogServer (src/catalog.ts, exported as @ensemble/context-bridge/catalog): the core tools and resources plus named operations instead of catch-alls (§5.4). x-ensemble-tools: catalog selects that profile for a static key; legacy selects the existing tool names. This changes metadata, not grants.
Write tools require changes allowed and app tools require apps allowed. Hosted requests read the grants from the token; CLI and source builds ask GET /api/bridge/whoami at start (buildServerForKey). The hosted endpoint cannot see the editor's disk: ensemble_brief needs task or repo and branch, ensemble_upload_file takes dataBase64, and ensemble_get_file cannot use saveTo. Local bridges detect the repository, read a path and save into the working folder. Actions and connected apps run on Ensemble's server in either profile.
2. Architecture
Editor / MCP client
→ stdio bridge | loopback HTTP bridge | hosted /mcp
→ apps/context-bridge/src/server.ts (buildServer)
→ hub-api /api/bridge routes, scoped to the key's space or another its account owns
→ bridge/service.ts over saved workspace records
| Path | Responsibility |
|---|---|
apps/context-bridge/src/server.ts |
MCP tools and resources, their annotations; the write tools only for a key that may make changes, the app tools only for one that may use apps |
apps/context-bridge/src/catalog.ts |
Core tool reuse, named action/app schemas, fixed operation variants, OAuth tool schemes and input validation |
apps/context-bridge/src/hub.ts |
HTTP client for hub-api, auth and errors; the space a call names (x-ensemble-space) and file downloads |
apps/context-bridge/src/spaces.ts |
The space argument every tool takes: an id or exact name, resolved from GET /api/bridge/spaces |
apps/context-bridge/src/files.ts |
A file as MCP content (image, text, embedded resource) and where saveTo may write |
apps/context-bridge/src/config.ts |
Environment, token rules, local runtime discovery |
apps/context-bridge/src/git.ts |
Repository and branch from the working directory |
apps/hub-api/src/bridge/routes.ts |
Read routes |
apps/hub-api/src/bridge/docs.ts, apps/docs/src/search.js |
Public documentation index and shared word search, without private workspace records (40) |
apps/hub-api/src/bridge/write-routes.ts |
whoami, the task writes and file uploads (§5.1) |
apps/hub-api/src/bridge/actions.ts, action-routes.ts |
The actions allowlist, listing and running actions and app tools (§5.1, §5.2) |
apps/hub-api/src/bridge/service.ts |
Queries, reads, brief assembly, pagination; context and search over context/graph/ |
apps/hub-api/src/bridge/files.ts |
Files: details, summary and tags, Ensemble's text in parts, the original (§5) |
apps/hub-api/src/bridge/spaces.ts |
The spaces a key's account can open and which of them the key may act in (§4) |
apps/hub-api/src/bridge/resolve.ts |
Repository, branch and task resolution |
apps/hub-api/src/bridge/auth.ts |
Which credentials the bridge accepts, and which may write |
apps/hub-api/src/lib/auth.ts (keyIdentity) |
The space a key acts in: its own, or another its account owns named by x-ensemble-space (§4) |
apps/hub-api/src/bridge/gaps.ts |
Exclusion and missing-data text for ensemble_gaps (§7) |
apps/hub-api/src/routes/mcp.ts |
Hosted Streamable HTTP |
apps/hub-api/src/routes/mcp-oauth.ts, src/mcp-oauth/ |
OAuth discovery, DCR/CIMD, browser consent, PKCE, rotating credentials and revocation |
MCP server-info reports the release version from @ensemble/shared-types/version.
3. Run it from source
pnpm bridge:build # tsc → apps/context-bridge/dist
pnpm bridge:start # stdio (what editors launch)
pnpm bridge:http # Streamable HTTP on 127.0.0.1:4010
pnpm bridge:verify # hub-api bridge tests, then typecheck, MCP tests, build, smoke
hub-api has to be up (pnpm dev), or every tool returns: Ensemble is not running. Start it with `pnpm dev`.
The bridge calls HUB_API_URL, else the local desktop or CLI runtime from its api.json (DESKTOP §3.2), else http://$HUB_API_HOST:$HUB_API_PORT (default 127.0.0.1:4000). Found through api.json, it uses that file's launch token and reads the file again before each call, so a restarted app (new port and token) keeps working; tools then say The Ensemble app is not running on this computer when it is closed. This is how editors use a local-only desktop app without the CLI: Connect your apps there shows the command (GET /api/desktop/mcp, DESKTOP §1.2).
4. Token
The Hub's Connect your apps page (/connect) creates a key, fills in this computer's paths and offers one-click install for VS Code and Cursor.
-
Connect your apps → Create a key. The value starts with
ens_and is shown once. It can read/api/bridge(files included, §5), call/mcp, run the read actions and revoke itself at/api/cli/logout. Two grants, both ticked by default (POST /api/connect/token):- Let editors and agents make changes (
canWrite,api_tokens.can_write): the task writes, file uploads and every write action on the allowlist (§5.1). - Let editors and agents use your connected apps (
canUseApps,api_tokens.can_use_apps): the apps' tools through Ensemble (§5.2); changing something in an app also needs the first grant.
The key list shows each key's grants. Keys made before 0.7.1 only read (their task-update grant was narrower and was reset): make a new key, or run
ensemble loginagain (the/linkpage asks the same two questions). A key made under Settings → Connections → Editors & agents is a full key: it has both and can drive editor hooks. - Let editors and agents make changes (
-
Export it where the editor can see it:
export ENSEMBLE_BRIDGE_TOKEN=ens_...
ENSEMBLE_BRIDGE_TOKEN must start with ens_. With no token and no HUB_API_URL, the bridge uses a running desktop or CLI runtime's launch token from api.json. Otherwise every tool returns a configuration error; the bridge never falls back to the default internal token.
hub-api's ENSEMBLE_INTERNAL_TOKEN is the agent-runtime service credential and defaults to dev-internal-token, which hub-api warns about at startup. The bridge uses it only with ENSEMBLE_BRIDGE_USE_INTERNAL_TOKEN=true, and refuses the default value unless ENSEMBLE_BRIDGE_ALLOW_DEFAULT_INTERNAL_TOKEN=true is also set (local demos only, never on a shared network). Set a private one (openssl rand -hex 32) if anything besides this machine can reach port 4000. ENSEMBLE_DEV_AUTH_BYPASS is refused on /api/bridge/*.
Spaces. A bridge or full key acts in the space it was made in (its default). A request that names another space with x-ensemble-space: <space id> acts there when the key's account owns it (keyIdentity in apps/hub-api/src/lib/auth.ts, checked with openedSpace() on every request): the account's own space and the spaces it created (28). The account is api_tokens.account_id, set when Connect your apps, Settings or ensemble login makes the key (migration 20261126100000_api_token_account; ensemble login keeps the approving account on cli_auth_requests.account_id until the exchange). A key with no account_id acts for its own row's account when it was made in an account's own space, and stays in its space when it was made inside another. Refusals are one sentence and nothing falls back to another space:
| Request names | Answer |
|---|---|
| A space the account cannot open | 404 "This key's account cannot open that space." |
| A space shared with the account (a member's) | 403 "“‹space›” is shared with you, and keys act only in your own spaces: work there in Ensemble." The bridge's reads and actions act as a space's owner, and its routes are owner-only in the sharing policy (29) |
Another space, with a key that has no account_id and was made inside a space |
404 "This key works only in “‹space›”: make a new key in Ensemble to reach your other spaces." |
The MCP tools take the space as space (§5), GET /api/bridge/spaces lists the spaces, and GET /api/bridge/whoami says whether the key may name one. Device, hook and transfer keys and signed-in pages ignore the header; Ensemble Node's delegated requests use it with their own checks (29 §2).
Creating and revoking keys is a signed-in account action; a bridge key cannot mint another key.
4.1 OAuth for AI apps
An app adds the same /mcp URL and discovers /.well-known/oauth-protected-resource/mcp (also served at /.well-known/oauth-protected-resource) and /.well-known/oauth-authorization-server. Unauthenticated MCP gets a 401 Bearer challenge with resource_metadata. HUB_API_PUBLIC_URL is the issuer's HTTPS origin; HTTP loopback is allowed only in development. Metadata and OAuth endpoints (/.well-known/…, /oauth/…) never take a cookie, key or space header as the person's identity, so a key sent there unlocks nothing and is not refused; /oauth/token and /oauth/revoke read Authorization only as the app's client authentication.
POST /oauth/register supports public clients (none) and confidential client_secret_basic or client_secret_post clients. HTTPS client IDs can use CIMD; the public document must match its URL, include its name and redirects, and support none. Fetches are public-only, DNS-pinned, without user credentials or redirects, bounded to 5 seconds and 32 KB. Registered redirects match exactly; an HTTP loopback callback may vary only its port.
GET /oauth/authorize requires authorization-code flow and PKCE S256. The standalone web /oauth page is public, but its consent API requires the signed-in browser session. It preserves the request rather than detouring through onboarding. The person checks the app's name and domains, and approves its requested read/write/app grants and default owned space. Consent is bound to that browser session and expires after 10 minutes. Email signup and Google/GitHub/Microsoft sign-in retain the consent return. All the account's owned spaces remain reachable; shared spaces do not.
| Scope | Permission |
|---|---|
ensemble:read |
Required; existing bridge reads |
ensemble:write |
Existing canWrite actions and uploads |
ensemble:apps |
Existing canUseApps; app writes also need write |
offline_access |
Refresh after access expires; rotation by default |
POST /oauth/token exchanges a single-use code or refresh token. Explicit resource values must equal this server's /mcp URL; clients omitting it get that sole resource. Access tokens are hashed ens_ bridge keys expiring in one hour. Refresh tokens expire after 30 days and rotate on use; narrowing persists, and replay revokes the entire connection. Issuer identification is returned on success and error callbacks. There is no client_credentials, implicit grant, no-auth or mixed-auth mode.
Confidential registrations may explicitly request ensemble_refresh_token_mode: "persistent" for clients such as Google's Connected Apps persistent-linking flow. Every refresh must authenticate with the client secret; the same refresh credential is returned and its 30-day expiry extends. Narrowed permissions stay narrowed. Public and CIMD clients cannot select this mode and keep rotation/replay detection. Client publishing requirements are vendor-specific; standard public-client security is not weakened.
GET /.well-known/openai-apps-challenge serves the exact plaintext OPENAI_APPS_CHALLENGE value for OpenAI plugin domain verification. It returns 404 when unset, has no identity or workspace context, and is not cached. The operator supplies the portal-generated token for this API hostname; no challenge value or vendor approval is bundled with the code.
POST /oauth/revoke, Connect your apps → Disconnect, and self-logout revoke the whole token family. GET /api/mcp-oauth/connections lists only the signed-in account's connections; consent and disconnect mutations require the browser's origin and CSRF proof. Ephemeral access keys are excluded from manual key lists. Grant metadata is exported with the account; secrets are not. Account or default-space deletion cascades through grants and tokens (07).
5. Tools and resources
| Tool | Resource | Data |
|---|---|---|
ensemble_brief |
— | The task: task (an ensemble://task/<id> handle, a Hub link, its id or its exact open title), else repository + branch. workContext is the task's context packet (context/graph/packet.ts, at most 7,000 characters: where it came from with the words asked, project, people with roles and relationship, dependencies, related open work, done before, each with a handle) and contextRef its handle; plus project, people, deliverables, files attached to the task, current skills (howIWork) and cited excerpts (context) |
ensemble_context |
— | One record and what it links to (GET /api/bridge/context): item is a handle, a Hub link, or an id with kind (task, project, person, repo, deliverable, meeting, file, page, event). Returns the record and its relations, 6 each, every card with a ref to call again; rel lists one relation in full, paged with offset and limit (≤50); format=packet returns the Markdown packet (8,000 characters) |
ensemble_search |
— | Ranked word search (context/graph/search.ts) across tasks, projects, people, repositories, deliverables, meeting notes with transcripts, standalone pages, files (by name and summary, kind document), saved app items, skills, diagrams and plots; kinds filters, near (a handle) ranks what links to it first. Up to limit × 3 hits (at most 40), each with ref, score and why |
ensemble_docs |
None | Published product documentation, not workspace records (GET /api/bridge/docs): query (1 to 200 characters of search words), limit (1 to 20, default 8). Returns found, matching title, heading, public url and a short excerpt from the same pages as https://docs.ensemblework.com; a miss explicitly says so and returns the docs home. Title: Search Ensemble documentation; annotations: readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false. Neither changes nor app grants are needed. Public site and index: 40 |
ensemble_read |
ensemble://items/{kind}/{id} |
One item in full, paginated with offset and limit. Kinds: task (with its meeting, dependencies, labels and deliverable), project, repo, skill, deliverable, person, artifact, document (details and summary; its text and the file itself come from ensemble_get_file), meeting_note or meeting (the whole transcript, its people and the tasks made from it; a notes session's id reads its notes), approval, decision, diagram, plot, page |
ensemble_tasks |
ensemble://tasks, ensemble://tasks/{id} |
Tasks |
ensemble_projects |
ensemble://projects, ensemble://projects/{id} |
Projects |
ensemble_repos |
ensemble://repos, ensemble://repos/{id} |
Repositories |
ensemble_repo_overview, ensemble_repo_file |
— | Outline and source of a checkout the person already allowed; secrets and protected paths are skipped |
ensemble_diagrams, ensemble_plots |
— | Saved diagrams and plot context |
ensemble_skills |
ensemble://skills, ensemble://skills/{id} |
Current enabled skills; one skill's current body |
ensemble_deliverables |
ensemble://deliverables |
Deliverables |
ensemble_meetings |
ensemble://meetings |
Upcoming calendar events (Artifact kind event) from Google Calendar, Outlook and calendar links, one row per event (03 §2.5), each with previous: the latest earlier meeting of its series (03 §7) |
ensemble_people |
ensemble://people, ensemble://people/{id} |
Engineer Graph people |
ensemble_decisions |
ensemble://decisions |
Needs me: pending approvals and editor prompts (read only) |
ensemble_today |
ensemble://today |
In the person's time zone: focus, proposed todos, tasks ready for review (waiting_approval), what others owe (open tasks labelled waiting-on), deliverables, meetings, Needs-me counts |
ensemble_gaps |
ensemble://gaps |
What the bridge cannot see or refuses to expose |
ensemble_files |
None | The person's files, newest first (GET /api/bridge/files): name, format, media type, size, pages, summary, source app and handle. query matches the name, summary or text; on (a task, page, project, person or repository handle) lists the files on that record, as its page shows them; limit (≤50) and offset |
ensemble_get_file |
None | One file (GET /api/bridge/files/:id, bridge/files.ts): its details (id, name, format, media type, size, pages, tags by name, created, link in Ensemble) and summary as JSON; text: true adds Ensemble's text of it in parts (offset, limit ≤20,000 characters); and the file itself unless content: false: a PNG, JPEG, GIF or WebP picture up to 5 MB as an image block, a text or code file as text (up to 200,000 characters), anything else (PDF, Office…) as an embedded resource with blob and media type. One answer carries a file up to 20 MB; a larger one is named with what to do. Through the CLI or a source build, saveTo writes the original into the working folder instead, any size (GET /api/bridge/files/:id/original): a path inside the folder, relative or absolute, never with .., never through or onto a symbolic link, and onto an existing file only with overwrite: true (apps/context-bridge/src/files.ts) |
ensemble_spaces |
None | The spaces the key's account can open (GET /api/bridge/spaces): id, name, access (own or member) and role, the key's default, the request's current, and usable (false, with refusal, for a space shared with the account) |
Every tool but ensemble_spaces takes an optional space: a space's id or exact name (compared as the space switcher compares names). apps/context-bridge/src/spaces.ts resolves it from GET /api/bridge/spaces (kept 30 seconds) and sends it as x-ensemble-space with each request of that call; hub-api checks it again (§4). A space shared with the account is refused before any call. Resources read the key's default space.
5.1 Changes in Ensemble
ensemble_actions and ensemble_act are offered to every key; the other write tools only to a key that may make changes (§4). read-only.ts refuses to start with a write-named tool outside ensemble_create_task, ensemble_update_task and ensemble_upload_file.
| Tool | Route | What it does |
|---|---|---|
ensemble_actions |
GET /api/bridge/actions (groups, names, schema) |
The actions this key may run, by group (below), each with write and allowed (and refusal when not), and limit when a guard refuses some inputs; notOffered says what stays with the person. With names, their full descriptions and inputSchema |
ensemble_act |
POST /api/bridge/act/:name {input} |
Runs one action: the assistant's own Hub tool of that name (bridge/actions.ts BRIDGE_ACTIONS), with its validation, access checks, Undo entry and live refresh, at once (no Apply). A wrong input is a 400 with the action's inputSchema. Writes need changes allowed, are ledgered as bridge.<tool> with the key's name and answer undoable |
ensemble_create_task |
POST /api/bridge/write/tasks |
{title, page?, projectId? | projectName?, due? (YYYY-MM-DD), people? (names or ids), status? (proposed, default, or todo), from? (a handle)}. Adds a task through createTask with actor agent: page becomes its page, from adds a Related: link to that record, names become person ids where one person has that name |
ensemble_update_task |
PATCH /api/bridge/write/tasks/:id |
{status? (in_progress, waiting_approval, done, blocked, todo), note?, matchTitle?}. A status follows the task state machine (a refused move lists the allowed ones); note is added to the task's page as an agent block titled "Update from <key name>"; a matchTitle that differs is a 409 |
ensemble_upload_file |
POST /api/bridge/write/files |
{filename, dataBase64, mediaType?, attachTo?, summarize?}; through the CLI or a source build, path (a file on the editor's computer) instead. Saved through POST /api/documents (the same checks, caps and text extraction as an upload in Files); attachTo (a task, page, project, person or repository handle) tags the file, and for a task or page adds [@file](ensemble://file/<id>) to the end of its page. Ledgered as bridge.file.upload |
The allowlist (each name is the assistant's tool, 34). Reads run for every key; changes need changes allowed, and those marked apps also need apps allowed:
| Group | Reads | Changes |
|---|---|---|
| guides | hub_how_to (the assistant's playbooks: boards, diagrams, plots…) |
— |
| tasks | hub_list_tasks, hub_get_task, hub_read_task_page, hub_list_due |
hub_create_tasks, hub_update_task (title, status, owner, priority, due, labels, people, project, repository, deliverable, page), hub_delete_task, hub_delete_tasks (many, to Trash) |
| pages | hub_list_pages, hub_read_page, hub_read_page_blocks |
hub_create_page, hub_update_page, hub_edit_page, hub_move_page, hub_delete_page, hub_delete_pages. People and records are linked in a page as [@Name](ensemble://person/<id>) |
| boards | hub_list_boards, hub_read_board |
hub_create_board, hub_update_board, hub_move_to_board (a task or page into a column), hub_remove_from_board, hub_delete_board (keeps its cards). Task status columns on the Ensemble board move with hub_update_task |
| diagrams | hub_list_diagrams, hub_get_diagram, hub_validate_diagram, hub_explain_diagram, hub_diagram_context |
hub_create_diagram, hub_update_diagram, hub_edit_diagram |
| plots | hub_list_plots, hub_get_plot, hub_list_datasets, hub_get_dataset |
hub_import_dataset, hub_create_plot, hub_update_plot |
| files | hub_list_files, hub_file_edit_info (the files themselves: ensemble_get_file, §5) |
New files only: hub_file_to_page, hub_export_page_file (refuses replaceFileId), hub_save_file (from a web link), and apps hub_save_app_item_as_page, hub_import_app_file |
| projects | hub_list_projects, hub_list_deliverables |
hub_create_project, hub_update_project (name, summary, notes, status, people, repositories), hub_create_deliverable, hub_update_deliverable, hub_complete_deliverable, hub_delete_deliverable |
| people | hub_list_people, hub_list_repos |
hub_create_person, hub_update_person (role, team, relationship to the person), hub_delete_person, hub_link_repo, hub_unlink_repo, hub_update_repo |
| meetings | hub_list_meeting_notes, hub_read_meeting_note |
hub_create_meeting_note, hub_update_meeting_note, hub_delete_meeting_note, hub_suggest_meeting_tasks, apps hub_import_meeting_notes |
| comments | hub_read_record_details |
hub_manage_record: comments, restore any record from Trash, a page into a task (convert_page), file tags, copying or deleting a plot or diagram, renaming or deleting a dataset, a diagram's earlier version |
| context | hub_item_context, hub_search_work, hub_search_context, hub_read_context, hub_read_surface (Today, Board, Context and the other screens), hub_list_approvals (Needs me) |
hub_delete_entity (a project, repository or skill to Trash; refuses document), hub_restore_entity |
| skills | hub_list_skills, hub_read_skill |
hub_manage_skill (create, update, restore a version, delete) |
| routines | hub_list_routines |
hub_create_routine, hub_update_routine (edit, pause, resume; refuses delete) |
| reminders | hub_list_reminders, hub_list_watchers |
hub_create_reminder, hub_update_reminder, hub_dismiss_reminder, hub_delete_reminder |
| desk | hub_list_desk_items |
hub_add_desk_item, hub_update_desk_item, hub_remove_desk_item |
| calendar | hub_list_events, hub_weekly_recap |
None |
| runs | hub_list_activity, hub_list_runs, hub_get_run |
None |
| agents | hub_list_agent_sessions, hub_list_agent_options |
None |
| code | hub_repo_overview, hub_repo_read_file, hub_list_code_folders, hub_list_agent_changes |
None |
| settings | hub_find_setting, hub_get_settings, hub_list_model_choices |
None |
| layouts | hub_read_layout |
None |
| imports | hub_read_import, apps hub_preview_board_import |
apps hub_start_board_import, hub_manage_import (cancel, undo) |
| relationships | hub_list_connections, hub_list_app_relationships |
apps hub_manage_app_relationship, apps hub_fetch_now |
An action runs in the request's space (the key's own, or one it named, §4) as the person (route-backed tools call the API with the internal credential and x-ensemble-user), with the space's modules. What changes how Ensemble behaves or starts work stays with the person in Ensemble: a name the assistant has but the list does not answers 404 saying so (hub_update_settings, hub_choose_model, hub_edit_layout, hub_stop_activity, hub_assign_to_agent, hub_run_workflow, hub_create_watcher, hub_cancel_watcher, hub_edit_file; hub_view_file points to ensemble_get_file, and hub_web_search and hub_read_web_page are the assistant's own). Guards refuse deleting a file (hub_delete_entity kind document: "Files cannot be deleted from editors and agents; delete it in Ensemble."), deleting a routine and replacing a file's original (403). No action stops or deletes a run or starts the assistant.
A coding agent Ensemble started keeps the everyday part of the list (runActionCaller in workspace/context-tools.ts): the entries not marked keyOnly in BRIDGE_ACTIONS, without deleting a dataset or restoring a skill or reminder; anything else answers "Agent runs cannot use ‹name›…" (30 §9).
GET /api/bridge/whoami answers {canWrite, canUseApps, apps (connected app names), spaceId, spaceName (the request's space), defaultSpace {id, name} (the key's own), crossSpace, crossSpaceNote?, keyName}. Writes are refused (403) for a key without changes allowed, device and hook keys, and every other route; a signed-in page and a full key may use them. Each write refreshes open pages (task, page, documents or assistant.acted events). POST /api/bridge/act/* and /api/bridge/write/* share the hosted MCP rate limit (mcp, 240 a minute per space).
5.2 Connected apps
| Tool | Route | What it does |
|---|---|---|
ensemble_apps |
GET /api/bridge/apps (app, names, schema) |
The apps connected in the request's space (assistant/connections.ts), what Ensemble can do in each, apps that are connected but broken, and their tools, each with its first sentence (all of an MCP server's switched-on tools, not one assistant turn's share: appCatalog); app narrows it to one app, and named tools come with their full description and inputSchema. A key without apps allowed gets {allowed: false, refusal} |
ensemble_app_call |
POST /api/bridge/act/:tool {input} |
Calls one app tool with the person's own connection: the built-in Google Workspace, Microsoft 365, Zoom, DocuSign and Jira tools, apps_search/apps_read/apps_post_update across the apps Ensemble can search (Notion, GitHub, Linear, Slack, Figma, Vercel, Zotero, Hugging Face…, 31), and each connected MCP server's own tools (mcp_<server>_<tool>) |
Both tools exist only for a key with apps allowed. App tools are offered and checked as for the assistant (assistant/apps.ts): the suite connected, the product switched on, the scopes granted; app writes are left out when Settings → Assistant turns connected-app writes off. A change in an app also needs changes allowed, runs at once through runOutsideWrite (ledgered as apps.<tool>), and cannot be undone from Ensemble. Calls run on Ensemble's server, so the hosted URL and the CLI reach the same apps and the editor needs no app keys.
5.3 What each way in can do
| MCP key (editors, agents) | Hub assistant | Ensemble Node | |
|---|---|---|---|
| Read Ensemble, context graph, files and their contents | Yes, in every space the key's account owns | Yes | Yes, in every space its owner can open |
| Read settings, layouts, runs, agent sessions, Needs me | Yes | Yes | Yes |
| Changes to records (§5.1): tasks, pages, boards, diagrams, plots, projects, people, deliverables, meeting notes, comments, skills, routines, reminders, desk lists, imports, bulk deletes | With changes allowed; at once, Undo | Yes; Apply or at once, by write policy | Yes |
| New files | With changes allowed | Yes | Yes |
| Connected apps | With apps allowed | Yes | Yes |
| Settings, model choice, layouts | Read only | Yes (wait for Apply) | Yes |
| Deleting a routine or a file; editing a file's original | No | Yes | Yes |
| Assigning work to agents, workflows, watchers, stopping runs | No | Yes | Yes, plus its own sessions, computers and crews |
A coding agent that Ensemble starts on a paired computer has ensemble_actions and ensemble_act in its run's own MCP server, under the job's lease, with the everyday part of the list (§5.1), and ensemble_apps and ensemble_app_call with the connected apps as above (§5.2; the person's own agent in Chat has the apps too), only in its owner's own space (30 §9). So every way an agent reaches Ensemble (a key in an editor, a run Ensemble starts, the assistant, a node) reaches the person's connected apps through it.
A resource URI is an MCP identifier, not permission to open local files. Reads are scoped to the request's space: the key's own, or another its account owns (§4, 28); module checks still apply.
5.4 Named catalog profile
The hosted catalog keeps the existing core reads, resources and dedicated task/file writes. ensemble_actions, ensemble_act, ensemble_apps and ensemble_app_call are replaced by ensemble_action_<name> and ensemble_app_<name> tools built from the same allowed action and connected-app schemas. Names stay within 64 characters; changed or shortened names get a deterministic hash suffix. Enum-based action, operation or method choices become separate fixed-operation tools. No call can select a different underlying action.
Each tool has a title and explicit read-only, destructive, idempotent and open-world hints, plus its required OAuth scopes. Only allowed tools are listed; original API validation, module checks, ownership, grants, Undo and app-write policy still apply. Every tool takes optional space; a provider tool whose own schema has space keeps its arguments under input. Inputs are validated against the published schema before dispatch. Tool errors are MCP errors, not empty results.
- Files.
ensemble_readof adocumentgives a file's name, format, size, page count, summary and link in Ensemble, and search matches a file's name and summary.ensemble_get_filegives Ensemble's text of it when asked and the file itself (§5); the extracted text stays out of search, briefs andensemble_read(32,files/extracted-text-policy.ts). A task or page also lists its files. A key adds files (ensemble_upload_file,hub_save_file, page exports) and never edits an original or deletes a file. - Pages.
apps/hub-api/src/pages/agent-context.tsadds the source of diagrams embedded in task and standalone pages, with space ownership checks. For the bridge, page bodies are bounded at 15,000 characters and diagram source at 20,000, then paginated. Converted source documents appear in the page's file citations (files/attached.ts). - Skills. Only the current body is returned. Older
SkillVersionbodies are not exposed (hub_read_skilllists recent versions without them), and disabled or deleted skills are left out of briefs (04). - Trust labels. Mail, chat, GitHub text, calendar invites, approval previews and editor tool inputs are labelled
trust: "untrusted"; skills aretrusted. The brief repeats that retrieved text is reference material, not instructions. - Resolution. A branch that matches no task, or several, is reported as such; the bridge never picks a similarly named task.
6. Limits
- Search is ranked word matching plus graph links, not semantic:
Artifact.searchVectorandArtifactChunk.embeddingexist in the schema but nothing fills or queries them; enabling an embedding provider does not change this. - Not given: older skill bodies; reminders and layouts only through the read actions. Not changed: anything off §5.1's list, and Needs-me answers.
- Files: one answer carries an original up to 20 MB (
FILE_ANSWER_MAX_BYTESinbridge/files.ts); a larger one comes throughsaveToin the CLI or a source build, orGET /api/bridge/files/:id/original. Ensemble's text of a file is its extracted text: no OCR, audio or video. - Spaces: a key reaches the spaces its account owns. Spaces shared with the account are refused, and a key with no
account_idmade inside a space stays in that space (§4). - Connected-app copies can be stale until refreshed; bridge reads do not call vendor search.
- The bridge returns stored text without a new redaction pass; ingest and file paths have their own redaction.
- OAuth is authorization-code/PKCE only. A static key or stdio remains available; anonymous access to workspace tools is not offered.
The task-scoped scripts/ensemble-ask-mcp.mjs server used inside managed engine runs asks questions for its run and runs the everyday actions under the job's lease. It is not the Context Bridge and does not widen an editor's ens_ key (30).
7. What ensemble_gaps says
apps/hub-api/src/bridge/gaps.ts lists what the bridge does not have or will not give, so a model does not invent it: no embedding search (retrieval is ranked words plus graph links), no redaction pass on bridge reads (ingest and file paths redact), mail, chats and transcripts only when the person saved or imported them, layouts and reminders only through the read actions, no older skill bodies, files (the 20 MB answer and saveTo), other spaces (§4), which changes a key may make (§5.1) and how the connected apps are reached (§5.2). It is not the connector inventory (31).
8. Transports
- stdio (default). No port. Editors launch
node apps/context-bridge/dist/index.js, orensemble mcpfrom the CLI. - Hosted Streamable HTTP at
https://api.ensemblework.com/mcp. An OAuth access token or staticBearer ens_key is required; cookies and device keys are refused. Unauthenticated requests receive discovery challenges. AuthenticatedGETandDELETEreturn 405.x-ensemble-spacegoes with every call unless a tool names its ownspace(§4). - Local Streamable HTTP (
--http) athttp://127.0.0.1:4010/mcp.CONTEXT_BRIDGE_PORTchanges the port;CONTEXT_BRIDGE_HOSTmust be127.0.0.1,localhostor::1, or the bridge refuses to start. OptionalCONTEXT_BRIDGE_HTTP_TOKENrequiresAuthorization: Beareron that endpoint; without it,ensemble_upload_filedoes not read apath(any process on the computer can reach a loopback port). The Hub token stays in the bridge process.
hub-api listens on HUB_API_HOST (default 127.0.0.1). infra/docker-compose.yml publishes Postgres (5432) and Redis (6379) on 127.0.0.1 only, so use 127.0.0.1, not localhost, in DATABASE_URL and REDIS_URL.
9. Client setup
Prefer Connect your apps in the Hub or ensemble mcp setup. Connect your apps has CLI, hosted-URL and from-source guides for VS Code, Cursor, Windsurf, Claude Code, Claude Desktop, Codex, Gemini CLI, GitHub Copilot CLI, Zed, Visual Studio, JetBrains, Cline, Continue, opencode, Qwen Code, Kimi Code and Mistral Vibe (through vibe mcp add) on macOS, Windows and Linux (apps/hub-web/lib/connect/configs.ts).
ensemble mcp setup grok uses Grok Build's own grok mcp add command for stdio or a bearer URL. AI apps and native MCP automation clients use the hosted URL with OAuth or their documented bearer-header option. Claude/Duet, ChatGPT/dots, Cursor, Grok/Grok Bot, Gemini, Muse, Copilot Studio, n8n, Zapier and Make share that server; each vendor controls client availability and approvals. Open-source plugin packages, client helpers and automation templates live in ensemblework/ensemble-integrations. A package or registry entry is not vendor approval or curated placement.
The from-source samples are in apps/context-bridge/clients/. Build first (pnpm bridge:build), use absolute paths and never commit a real token. Committed project files use ${env:ENSEMBLE_BRIDGE_TOKEN} or ${ENSEMBLE_BRIDGE_TOKEN}. ${input:…} is avoided because it drops the server from VS Code's Agent Host. One-click links (vscode:mcp/install, cursor://anysphere.cursor-deeplink/mcp/install) keep the key in the environment, not the URL.
| Client | Config | Sample |
|---|---|---|
| VS Code (Copilot agent mode) | Committed .vscode/mcp.json (servers); or the profile mcp.json with an absolute path. Reload, then enable the server in the Chat tools list |
vscode.mcp.json |
| GitHub Copilot CLI | ~/.copilot/mcp-config.json (mcpServers; it does not read .vscode/mcp.json). Or copilot mcp add ensemble -- node "$PWD/apps/context-bridge/dist/index.js", then put the token in that file's env. /mcp in a session lists it |
copilot-cli.mcp.json |
| Cursor | Committed .cursor/mcp.json, or ~/.cursor/mcp.json (mcpServers) |
cursor.mcp.json |
| Claude Code | Committed .mcp.json, or the command below |
claude-code.mcp.json |
| Claude Desktop | mcpServers in claude_desktop_config.json: ~/Library/Application Support/Claude/ (macOS), ~/.config/Claude/ (Linux), %APPDATA%\Claude\ (Windows). Absolute path; restart the app |
claude-desktop.mcp.json |
| Codex CLI | Merge into ~/.codex/config.toml |
codex.config.toml |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
windsurf.mcp.json |
| Any client, local HTTP | Run pnpm bridge:http; type: "http", url: "http://127.0.0.1:4010/mcp" (servers for VS Code, mcpServers elsewhere) |
http.mcp.json |
claude mcp add --transport stdio \
--env ENSEMBLE_BRIDGE_TOKEN="$ENSEMBLE_BRIDGE_TOKEN" \
--env HUB_API_URL=http://127.0.0.1:4000 \
ensemble -- node "$PWD/apps/context-bridge/dist/index.js"
The repo also ships .github/agents/ensemble.md (a custom agent that calls ensemble_brief and cites sources) and .github/skills/ensemble-context/SKILL.md (copy the folder to ~/.copilot/skills/ensemble-context/ to load it in Copilot CLI everywhere).
10. Failure behaviour
| Situation | Result |
|---|---|
| hub-api down | Ensemble is not running. Start it with `pnpm dev`. |
Token missing, not ens_, or the default internal token |
A configuration error, never an empty brief |
| Repo not tracked | Says so; searches only if a query was passed |
| Branch matches no open task | Repo and project context, no guessed task |
| Branch matches several tasks | The candidates; none is chosen |
| Nothing relevant | Says so |
| Published docs cannot be loaded or validated | ensemble_docs reports a tool error with the docs home, not an empty match or guessed answer |
| A space the key cannot act in | One sentence saying why (§4); nothing falls back to another space |
| Long body | Excerpt plus ensemble_read |
11. Tests
pnpm --filter @ensemble/hub-api test:mcp-oauth exercises the hosted OAuth routes and an MCP SDK client against in-memory PGlite: metadata, DCR/CIMD, consent, PKCE, ownership, client/resource binding, single-use codes, rotating refresh/replay, confidential persistent refresh, disconnect, domain proof and legacy keys. test/catalog.test.ts checks named dispatch and schema/space isolation; web mcp-consent.test.tsx and provider sign-in tests cover grants and returns. These are protocol/component tests, not a claim of live approval by a vendor directory.
pnpm --filter @ensemble/hub-api test:bridge # resolution, auth, write gating; context, brief by task, ranked search, task writes, actions, uploads, files, spaces and apps on PGlite
pnpm --filter @ensemble/context-bridge test # in-memory MCP client, stdio, Streamable HTTP, mocked hub-api, file content, saveTo rules, space
pnpm bridge:verify # both, plus typecheck, build and dist smoke
pnpm bridge:e2e # live Postgres, hub-api, both transports, security
The smoke script (apps/context-bridge/scripts/smoke.ts) speaks MCP to the built server against a mocked hub-api and checks tools/list, a tool call and resources.
pnpm bridge:e2e (scripts/live-e2e.ts) creates the ensemble_pg volume if needed, starts infra/docker-compose.yml, writes a gitignored .env when missing, runs db:generate, db:push and db:seed, builds the bridge, starts hub-api on 127.0.0.1:4000 with ENSEMBLE_DEV_AUTH_BYPASS=true, and mints a real ens_ key through POST /api/connect/token. The seed covers every status, a task linked to encode/httpx on branch docs/default-timeout, two tasks on retry-helper, deliverables, calendar events in the next 14 days, meeting notes, a file and a document, and a pending editor decision; the script also uploads latency-readout.md and opens a Needs-me prompt.
It then connects with the MCP SDK over stdio (as the editor configs do) and over Streamable HTTP at http://127.0.0.1:4010/mcp, calls every tool, reads every resource and item template, and compares payloads with the database (counts, ids, titles, trust labels). Security checks cover bridge-key writes (403 elsewhere in hub-api, 405 on /api/bridge), key minting, refusal of ENSEMBLE_DEV_AUTH_BYPASS and dev-internal-token on bridge routes, loopback binds, refusal of a non-loopback host, missing and invalid tokens, hub-api down, and Postgres/Redis refusing non-loopback connections.
The script leaves hub-api and the HTTP bridge running, writes logs under /tmp/ensemble-bridge-e2e/ and a JSON summary to bridge-e2e-report.json in the repo root, and never prints the token.
A passing handshake does not show that an editor picks the right tool or that a remote client supports bearer headers.