Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/member-mcp-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/hosted-server": patch
"@executor-js/app-templates": patch
---

Organization members can list, create, update and revoke their own scoped MCP connections, and mint and revoke keys on those connections, from the dashboard or with their own API key. A connection still only includes apps the member can use and personal accounts they own. An Executor app self-call keeps the calling connection's policy: it cannot widen to all apps or mint a wider key, and a narrowed connection without manage is refused. Another member's connections stay unreachable.
12 changes: 10 additions & 2 deletions apps/hosted/cloud/src/infrastructure/app-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const appOutboundBindings = Effect.gen(function* () {
const OutboundProps = Schema.Struct({
appOutbound: Schema.NonEmptyString,
hostApi: Schema.optionalKey(HostApiCheck),
callerConnection: Schema.optionalKey(Schema.String),
});
/** The private network service, as the outbound sends to it. */
interface Network {
Expand Down Expand Up @@ -98,9 +99,13 @@ export const appCredentialOutbound = Effect.gen(function* () {
/** The network one app's isolates use for global `fetch`. */
outbound: Effect.gen(function* () {
const exports = yield* loopback;
return (app: string, hostApi: HostApiCheck | undefined) =>
return (app: string, hostApi: HostApiCheck | undefined, callerConnection?: string) =>
exports.default({
props: hostApi === undefined ? { appOutbound: app } : { appOutbound: app, hostApi },
props: {
appOutbound: app,
...(hostApi === undefined ? {} : { hostApi }),
...(callerConnection === undefined ? {} : { callerConnection }),
},
});
}),
/** Serve a request sent by an app's isolate, or pass any other request to `handler`. */
Expand Down Expand Up @@ -135,6 +140,9 @@ export const appCredentialOutbound = Effect.gen(function* () {
credentialFetch(source, {
app: props.value.appOutbound,
...(props.value.hostApi === undefined ? {} : { hostApi: props.value.hostApi }),
...(props.value.callerConnection === undefined
? {}
: { callerConnection: props.value.callerConnection }),
key: sealing,
// Cloud's own origin is public, so it needs no exemption.
egress: { refusePrivateAddresses: !privateFetch, selfOrigin: undefined },
Expand Down
15 changes: 9 additions & 6 deletions apps/hosted/server/src/contracts/mcp-connections.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* A member's own scoped MCP connections and connected agents in one organization. Browser sessions
* manage both. An organization admin's full-authority API key or API grant may also list, create,
* update and revoke the admin's own connections, so agents can be provisioned without a browser;
* connected agents stay browser-only.
* A member's own scoped MCP connections and connected agents in one organization. A member lists,
* creates, updates and revokes their own connections, and mints and revokes keys on them, from a
* browser session or from an API key or full API grant that carries their authority. Each saved
* entry is limited to apps the member can use and accounts the member can use. Another member's
* connections are omitted and cannot be changed. Connected agents stay browser-only.
*/
import {
AccountNotFound,
Expand Down Expand Up @@ -93,8 +94,10 @@ export const ConnectionKeyNotFound = ApiError.define({
});
export type ConnectionKeyNotFound = typeof ConnectionKeyNotFound.Type;
/**
* MCP grants, including a connection's own, cannot manage connections: the API refuses MCP tokens.
* Connections only narrow their owner's own access, so an admin key cannot widen anything.
* The API refuses MCP tokens. An Executor app self-call is the caller's managed key plus the
* connection the host attached. That connection's policy is the authority: create, update and
* mint stay within its apps, tools and runsAs accounts, with no all-apps escalation. A narrowed
* connection without manage is refused. A key's lifetime stays within 5 seconds and 90 days.
*/
export const HostedMcpConnections = HttpApiGroup.make("mcpConnections")
.add(
Expand Down
203 changes: 172 additions & 31 deletions apps/hosted/server/src/implementation/mcp-connections.ts

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6825,6 +6825,28 @@ export const scenarios = plan({
local: na("The shared connections page is exercised through the hosted dashboard."),
},
},
memberConnectionNoWidening: {
fixtures: "actors",
file: "mcp-connections.spec.ts",
title:
"A full member connection can create a narrower Mesh connection through the Executor app, and a scoped connection cannot create an all-apps connection or mint a wider key",
targets: {
"self-host": scheduled,
cloud: scheduled,
local: na("Local has one operator and no organization roles or managed Executor keys."),
},
},
memberOwnScopedConnections: {
fixtures: "actors",
file: "mcp-connections.spec.ts",
title:
"Members create, list, update and revoke their own scoped connection from the dashboard and with an API key, mint and revoke a key on it, and cannot use another member's connection, an account they do not own, or an app they cannot see",
targets: {
"self-host": scheduled,
cloud: scheduled,
local: na("Local has one operator and no organization roles or API keys."),
},
},
localScopedConnections: {
file: "local-mcp-connections.spec.ts",
title: "Local scoped connections limit an OAuth client to their tools and revoke it",
Expand Down Expand Up @@ -8496,7 +8518,7 @@ export const scenarios = plan({
fixtures: "actors",
file: "connection-api-keys.spec.ts",
title:
"an admin's API key creates, uses and revokes a scoped connection without a browser, and a member's key cannot",
"an API key creates, uses and revokes its user's scoped connection without a browser, and a member's key cannot see or change an admin's connection",
targets: {
"self-host": scheduled,
cloud: scheduled,
Expand Down
24 changes: 18 additions & 6 deletions e2e/tests/connection-api-keys.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Agents are provisioned without a browser: an organization admin's API key creates a scoped
* connection, the same key runs at the connection's URL with only that connection's narrower
* policy, and revoking it stops those calls. Members' keys cannot manage connections.
* Agents are provisioned without a browser: an API key creates a scoped connection for its own
* user, the same key runs at the connection's URL with only that connection's narrower policy,
* and revoking it stops those calls. A member's key manages that member's connections and cannot
* see or change another member's.
*/
import { expect, layer } from "@effect/vitest";
import { Effect, Exit, Redacted, Schema } from "effect";
Expand Down Expand Up @@ -83,13 +84,14 @@ layer(HostedLive, { excludeTestServices: true })("Connection API keys", (it) =>
{ app: scopedApp.app.id, runsAs: [{ kind: "app" }], tools: { kind: "readOnly" } },
];

// A member's key cannot manage connections, even its own.
const refused = yield* asKey(member, "POST", `${prefix}/mcp-connections`, {
// A member's key creates that member's own connection.
const memberCreated = yield* asKey(member, "POST", `${prefix}/mcp-connections`, {
id: randomUUID(),
name: "Member connection",
apps: policy,
});
expect(refused.status).toBe(403);
expect(memberCreated.status, JSON.stringify(memberCreated.body)).toBe(200);
const memberOwned = yield* body(ConnectionView, memberCreated);

// An admin's key creates one and lists it, with no browser session.
const created = yield* asKey(admin, "POST", `${prefix}/mcp-connections`, {
Expand All @@ -103,6 +105,16 @@ layer(HostedLive, { excludeTestServices: true })("Connection API keys", (it) =>
const listed = yield* asKey(admin, "GET", `${prefix}/mcp-connections`);
expect(listed.status).toBe(200);
expect(JSON.stringify(listed.body)).toContain(connection.id);
expect(JSON.stringify(listed.body)).not.toContain(memberOwned.id);
// The member's key lists only their connection and cannot revoke the admin's.
const memberListed = yield* asKey(member, "GET", `${prefix}/mcp-connections`);
expect(memberListed.status).toBe(200);
expect(JSON.stringify(memberListed.body)).toContain(memberOwned.id);
expect(JSON.stringify(memberListed.body)).not.toContain(connection.id);
expect(
(yield* asKey(member, "POST", `${prefix}/mcp-connections/${connection.id}/revoke`))
.status,
).toBe(404);

// The same key at the connection's URL runs with only the connection's policy.
const scoped = yield* mcp.connect(admin.key, "key-at-connection", {
Expand Down
Loading
Loading