Skip to content
Closed
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 6 additions & 36 deletions apps/api/src/handlers/mcp/gbrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,18 @@ import {
isBrainEmbeddingAvailable,
resolveBrainConnection,
} from '@roomote/sdk/server';
import { GBRAIN_READ_TOOL_NAMES } from '@roomote/types';

import { rerankBrainQueryResult } from './gbrain-rerank';
import { createMcpProxy, McpProxyError } from './proxy-utils';

/**
* Read-only tool allowlist over gbrain's MCP surface, which publishes over a
* hundred tools. The list is filtered on `tools/list` as well as on calls, so
* an agent sees only these and never has to choose against the rest.
*
* `remember` and `forget` are deliberately absent: the agent path is
* structurally incapable of mutation, and memory writes flow only through
* the server-side ingestion pipeline with its own write-only credential.
*
* Deliberately absent for a second reason, that nothing here populates what
* they read:
* - `recall` leads with hot-memory facts saved via `remember`, which this
* deployment never writes. Its page arm duplicates `search`, so exposing it
* only offers a worse `search` with a permanently empty half.
* - `context_pack` and `delta` serve long-lived agents with standing entities
* and heartbeats. Roomote's agents are per-task and start cold.
*
* Keep this list in sync with the instructions in @roomote/types: a tool
* exposed but unexplained is one the agent picks by gbrain's own description,
* which is written for a different product.
* The agent-facing Brain tool set lives in `@roomote/types` next to
* `BRAIN_MCP_ID`, because the approval-rule compilers need the same list to
* tell which native keys are genuinely the Brain's. Re-exported here for the
* proxy's callers and tests.
*/
export const GBRAIN_READ_TOOL_NAMES = [
// Ask. `query` adds multi-query expansion and is the right default when the
// agent does not know the corpus vocabulary; `search` is the cheaper exact
// -token path with no expansion call.
'query',
'search',
// Exact, zero-LLM lookup for canonical person cards populated from Roomote
// member identities. Prefer this over broad search for a known person.
'entity',
// Reason across pages. Expensive and slow, but bounded in tokens, which is
// the only reason to prefer it over reading pages directly.
'synthesize',
// Browse: without these an agent can only answer questions it already
// knows to ask, and "what do you know?" looks like an empty Brain.
'list_pages',
'get_page',
] as const;
export { GBRAIN_READ_TOOL_NAMES };

/**
* Brain proxy: fronts the deployment-hosted gbrain HTTP MCP server
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/handlers/mcp/tool-approval-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@roomote/db/server';
import {
integrationToolModeIsAutoAssessed,
isInternalMcpServer,
resolveEffectiveIntegrationToolMode,
resolveGoverningIntegrationToolPolicies,
type IntegrationToolPolicyMode,
Expand Down Expand Up @@ -73,6 +74,12 @@ export async function resolveProxyToolApprovalBlocks(input: {
if (!(await isDeploymentExperimentEnabled('integrationToolApprovals'))) {
return result;
}
// Roomote's own MCP and other internal servers are outside approval
// control entirely; their tools always pass, with no Auto default or
// shadow assessment either.
if (isInternalMcpServer(input.integrationId)) {
return result;
}
const autoState = await resolveIntegrationToolAutoState();
result.shadowDefaultTools = autoState.mode === 'shadow';
if (autoState.mode === 'on' && input.tokenType === 'run') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
integrationToolApprovalDecisionSchema,
integrationToolSessionOverrideUpsertSchema,
isInternalMcpServer,
} from '@roomote/types';

import { authorize } from '@/lib/server/auth-context';
Expand Down Expand Up @@ -58,7 +59,19 @@ async function handle(
listPendingIntegrationToolApprovals(context),
listIntegrationToolSessionOverridesForRequester(context),
]);
return NextResponse.json({ pending, sessionOverrides }, { headers });
// Internal MCPs are outside approval control; never surface asks or
// overrides for them.
return NextResponse.json(
{
pending: pending.filter(
(approval) => !isInternalMcpServer(approval.integrationId),
),
sessionOverrides: sessionOverrides.filter(
(override) => !isInternalMcpServer(override.integrationId),
),
},
{ headers },
);
}

// Only configured public authority is trusted, never caller-supplied proxy headers.
Expand Down Expand Up @@ -93,6 +106,7 @@ async function handle(
body.value,
);
if (!override.success) return error(400);
if (isInternalMcpServer(override.data.integrationId)) return error(400);
await setIntegrationToolSessionOverride(context, override.data);
return NextResponse.json({ ok: true }, { status: 200, headers });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, type ReactNode } from 'react';

import {
integrationToolPolicyKey,
isInternalMcpServer,
type IntegrationToolPolicyMode,
} from '@roomote/types';

Expand Down Expand Up @@ -252,7 +253,13 @@ export function IntegrationToolApprovalList<T extends ManageableTool>({
toggleDisabled?: boolean;
}) {
const experiment = useIntegrationToolApprovalsExperiment();
const active = experiment.enabled && canManage && integrationId != null;
// Internal MCPs (Roomote's own server, the integrations broker, Brain
// memory) are outside approval control: no approval UI, ever.
const active =
experiment.enabled &&
canManage &&
integrationId != null &&
!isInternalMcpServer(integrationId);
const showLegacyAvailability =
!experiment.enabled &&
canManage &&
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 29 additions & 6 deletions apps/web/src/trpc/commands/integration-tool-policies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { resolveDecisionModel } from '@roomote/cloud-agents/server/typesafe-judgment';
import {
getMcpIntegration,
isInternalMcpServer,
type IntegrationToolAutoSettings,
type IntegrationToolPoliciesUpsert,
type IntegrationToolPolicyMode,
Expand All @@ -30,6 +31,20 @@ import type { UserAuthSuccess } from '@/types';

import { assertAdmin } from '../setup/shared';

/**
* Internal MCPs (Roomote's own server, the HTTP integrations broker, Brain
* memory) are excluded from approval control; reject attempts to configure
* them and hide any previously stored rows for them.
*/
function assertApprovalManagedIntegrationId(integrationId: string) {
if (isInternalMcpServer(integrationId)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${integrationId} is a Roomote-internal MCP server and is outside approval control.`,
});
}
}

/**
* Experiment-gated (`integrationToolApprovals`) per-tool approval policies.
* Deployment-scoped and admin-managed: every session on the deployment runs
Expand All @@ -39,33 +54,37 @@ export async function listIntegrationToolPoliciesCommand(
auth: UserAuthSuccess,
) {
assertAdmin(auth);
return listIntegrationToolPolicies();
return (await listIntegrationToolPolicies()).filter(
(policy) => !isInternalMcpServer(policy.integrationId),
);
}

export async function setIntegrationToolPolicyCommand(
auth: UserAuthSuccess,
input: IntegrationToolPolicyUpsert,
) {
assertAdmin(auth);
assertApprovalManagedIntegrationId(input.integrationId);
await upsertIntegrationToolPolicy({
...input,
updatedByUserId: auth.userId,
});
await syncLegacyDisabledTool({ ...input, scope: 'deployment' });
return listIntegrationToolPolicies();
return listIntegrationToolPoliciesCommand(auth);
}

export async function setIntegrationToolPoliciesCommand(
auth: UserAuthSuccess,
input: IntegrationToolPoliciesUpsert,
) {
assertAdmin(auth);
assertApprovalManagedIntegrationId(input.integrationId);
await upsertIntegrationToolPolicies({
...input,
updatedByUserId: auth.userId,
});
await syncLegacyDisabledTools({ ...input, scope: 'deployment' });
return listIntegrationToolPolicies();
return listIntegrationToolPoliciesCommand(auth);
}

const toolApprovalsEnabled = () =>
Expand Down Expand Up @@ -175,7 +194,9 @@ export async function listPersonalIntegrationToolPoliciesCommand(
auth: UserAuthSuccess,
) {
if (!(await toolApprovalsEnabled())) return [];
return listIntegrationToolUserPolicies(auth.userId);
return (await listIntegrationToolUserPolicies(auth.userId)).filter(
(policy) => !isInternalMcpServer(policy.integrationId),
);
}

export async function setPersonalIntegrationToolPolicyCommand(
Expand All @@ -188,13 +209,14 @@ export async function setPersonalIntegrationToolPolicyCommand(
message: 'Tool approvals are not enabled.',
});
}
assertApprovalManagedIntegrationId(input.integrationId);
await upsertIntegrationToolUserPolicy({ ...input, userId: auth.userId });
await syncLegacyDisabledTool({
...input,
scope: 'personal',
userId: auth.userId,
});
return listIntegrationToolUserPolicies(auth.userId);
return listPersonalIntegrationToolPoliciesCommand(auth);
}

export async function setPersonalIntegrationToolPoliciesCommand(
Expand All @@ -207,13 +229,14 @@ export async function setPersonalIntegrationToolPoliciesCommand(
message: 'Tool approvals are not enabled.',
});
}
assertApprovalManagedIntegrationId(input.integrationId);
await upsertIntegrationToolUserPolicies({ ...input, userId: auth.userId });
await syncLegacyDisabledTools({
...input,
scope: 'personal',
userId: auth.userId,
});
return listIntegrationToolUserPolicies(auth.userId);
return listPersonalIntegrationToolPoliciesCommand(auth);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/trpc/routers/_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isSetupModelProviderId,
JUDGMENT_MODEL_SELECTIONS,
isOpenAiCompatibleProviderId,
customMcpServerCreateInputSchema,
customMcpServerInputSchema,
customMcpServerVisibilitySchema,
isOpenAiRealtimeVoiceId,
Expand Down Expand Up @@ -1979,7 +1980,7 @@ export const appRouter = createRouter({

create: protectedProcedure
.input(
customMcpServerInputSchema.and(
customMcpServerCreateInputSchema.and(
z.object({ visibility: customMcpServerVisibilitySchema.optional() }),
),
)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading