Skip to content
35 changes: 35 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AuthProvidersManageScope,
AuthSettingsWriteScope,
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ProviderInstanceId,
ThreadId,
AuthOrchestrationOperateScope,
Expand Down Expand Up @@ -285,6 +286,40 @@ describe("settings mutation authorization", () => {
instanceId: ProviderInstanceId.make("codex_work"),
};

it.effect.each([false, true])(
"authorizes research host and search endpoint settings (grant=%s)",
(granted) =>
Effect.gen(function* () {
let handled = 0;
const client = yield* RpcTest.makeClient(group).pipe(
Effect.provide(
Layer.mergeAll(
group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
Effect.sync(() => {
handled++;
return DEFAULT_SERVER_SETTINGS;
}),
),
RpcAuthorization.layer(granted ? [AuthSettingsWriteScope] : []),
),
),
);
for (const webResearch of [
{ host: null },
{ host: EnvironmentId.make("research-bestia") },
{ searxngUrl: "http://configured-search.internal:8888" },
]) {
const request = client[WS_METHODS.serverUpdateSettings]({ patch: { webResearch } });
if (granted) yield* request;
else
expect(yield* request.pipe(Effect.flip)).toMatchObject({
requiredPermission: AuthSettingsWriteScope,
});
}
expect(handled).toBe(granted ? 3 : 0);
}).pipe(Effect.scoped),
);

it.effect("allows provider-only mutations while denying mixed settings without their grant", () =>
Effect.gen(function* () {
let handled = 0;
Expand Down
101 changes: 98 additions & 3 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,15 @@ import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolki
import * as WebResearchHandlers from "./toolkits/webResearch/handlers.ts";
import { WebResearchToolkit } from "./toolkits/webResearch/tools.ts";
import * as WebResearch from "../webResearch/WebResearch.ts";
import * as ResearchHost from "../webResearch/ResearchHost.ts";
import * as SessionRead from "../webResearch/SessionRead.ts";
import * as PageCache from "../webResearch/PageCache.ts";
import * as PublicHttp from "../webResearch/PublicHttp.ts";
import * as PublicRender from "../webResearch/PublicRender.ts";
import * as Search from "../webResearch/Search.ts";
import * as TinyFish from "../webResearch/TinyFish.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";

const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) =>
HttpServerRequest.toURL(request).pipe(
Expand Down Expand Up @@ -841,18 +847,107 @@ export const layerHtmlToolkit = Layer.mergeAll(
imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview),
).pipe(Layer.provide(HtmlRender.layer));

// Effect's generic registration displays Error.message instead of encoding the
// declared error schema. Research peers need the code as well as the message.
const registerWebResearch = Effect.fn("McpHttpServer.registerWebResearch")(function* () {
const server = yield* McpServer.McpServer;
const research = yield* ResearchHost.ResearchHost;
const threads = yield* ThreadManagementService.ThreadManagementService;
const built = yield* WebResearchToolkit;
for (const name of ["web_read", "web_search"] as const) {
const tool = WebResearchToolkit.tools[name];
yield* server.addTool({
tool: new McpSchema.Tool({
name: tool.name,
description: Tool.getDescription(tool),
inputSchema: Tool.getJsonSchema(tool),
outputSchema: Tool.getJsonSchemaFromSchema(tool.successSchema),
annotations: {
...Context.getOption(tool.annotations, Tool.Title).pipe(
Option.map((title) => ({ title })),
Option.getOrUndefined,
),
readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
destructiveHint: Context.get(tool.annotations, Tool.Destructive),
idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
},
}),
annotations: tool.annotations,
handle: (payload) =>
Effect.withFiber((fiber) =>
built.handle(name, payload).pipe(
Stream.unwrap,
Stream.run(Sink.last()),
Effect.flatMap(Effect.fromOption),
Effect.provideService(ResearchHost.ResearchHost, research),
Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
Effect.provideService(
McpInvocationContext.McpInvocationContext,
Context.getUnsafe(fiber.context, McpInvocationContext.McpInvocationContext),
),
Effect.map(
({ encodedResult }) =>
new McpSchema.CallToolResult({
structuredContent: encodedResult,
content: [{ type: "text", text: encodeJsonText(encodedResult) }],
}),
),
Effect.catchTags({
AiError: (error) =>
Effect.fail(
error.reason._tag === "ToolParameterValidationError"
? new McpSchema.InvalidParams({ message: error.reason.message })
: new McpSchema.InternalError({ message: "The research request failed." }),
),
NoSuchElementError: () =>
Effect.fail(
new McpSchema.InternalError({
message: "The research request returned no result.",
}),
),
OrchestratorMcpFailure: (error) => {
const failure = { code: error.code, message: error.message };
// outputSchema describes success. SDK clients also validate error structuredContent.
return Effect.succeed(
new McpSchema.CallToolResult({
isError: true,
content: [{ type: "text", text: encodeJsonText(failure) }],
}),
);
},
}),
),
),
});
}
});

// One environment-owned reader/cache/limiter is shared by all MCP sessions.
export const layerWebResearchToolkit = toolkitRegistration(
WebResearchToolkit,
WebResearchHandlers.layer,
export const layerWebResearchToolkit = Layer.effectDiscard(registerWebResearch()).pipe(
Layer.provide(McpToolAccess.HandlersLayer.layer(WebResearchHandlers.layer)),
Layer.provide(ResearchHost.layer),
);

const layerWebResearch = layerWebResearchToolkit.pipe(
Layer.provide(SessionRead.layer),
Layer.provide(
WebResearch.layer.pipe(
Layer.provide(
Search.layer.pipe(
Layer.provide(NodeHttpClient.layerNodeHttp),
Layer.provide(ServerSecretStore.layer),
),
),
Layer.provide(PageCache.layer),
Layer.provide(PublicHttp.layer),
Layer.provide(PublicRender.layer),
Layer.provide(
TinyFish.layer.pipe(
Layer.provide(NodeHttpClient.layerNodeHttp),
Layer.provide(ServerSecretStore.layer),
),
),
),
),
);
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/McpRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ export const invoke = <P, A, E, R>(
if (Option.isNone(scopeOption)) return yield* here;
const scope = scopeOption.value;
const environmentId = PeerForwarding.remoteTarget(scope, decoded.value.environmentId);
if ((tool.name === "web_read" || tool.name === "web_search") && environmentId !== undefined)
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message: "Select the research host with webResearch.host instead of environmentId.",
});
if (tool.name === "delegate_task" && decoded.value.environmentId !== undefined) {
const input = yield* decodeDelegate(params).pipe(Effect.mapError(unavailable));
if (
Expand Down
22 changes: 20 additions & 2 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,27 @@ const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe(
export const reads = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
declare((params: P) => McpRouting.invoke(params, handle));

export const readsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
// Peer agents have no destination thread; opt-in keeps other caller reads from widening.
export const readsAsCaller = <P, A, E, R>(
handle: (params: P) => Effect.Effect<A, E, R>,
options?: { readonly linkedCallers: "allowed" },
) =>
declare((params: P) =>
requireThreadCaller.pipe(Effect.flatMap(() => McpRouting.invoke(params, handle))),
McpInvocationContext.McpInvocationContext.pipe(
Effect.flatMap((scope) =>
options?.linkedCallers === "allowed" && scope.client?.linked === true
? scope.client.access === "read-only"
? Effect.fail(
new OrchestratorMcpFailure({
code: "capability_denied",
message: "This read requires a peer link with agent access.",
}),
)
: Effect.void
: requireThreadCaller,
),
Effect.flatMap(() => McpRouting.invoke(params, handle)),
),
);

export const actsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/mcp/WebResearch.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Search from "../webResearch/Search.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { expect, it } from "@effect/vitest";
Expand Down Expand Up @@ -26,10 +27,14 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
import * as EffectWorker from "../orchestration-v2/EffectWorker.ts";
import * as Registry from "../orchestration-v2/ProviderAdapterRegistry.ts";
import * as Replay from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
import * as TinyFish from "../webResearch/TinyFish.ts";
import * as WebResearch from "../webResearch/WebResearch.ts";
import * as PageCache from "../webResearch/PageCache.ts";
import * as PublicHttp from "../webResearch/PublicHttp.ts";
import * as PublicRender from "../webResearch/PublicRender.ts";
import * as Settings from "../serverSettings.ts";
import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as PeerLinks from "../peer/PeerLinks.ts";
import { type CachedPage, WebReadResult } from "../webResearch/schemas.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as Invocation from "./McpInvocationContext.ts";
Expand Down Expand Up @@ -74,6 +79,8 @@ const fixture = Effect.fnUntraced(function* (
{ runEffectWorker: false },
);
const reader = WebResearch.layer.pipe(
Layer.provide(Layer.mock(Search.Search)({})),
Layer.provide(Layer.succeed(TinyFish.TinyFish, { fetch: () => Effect.succeed(undefined) })),
Layer.provide(cacheLayer),
Layer.provide(
Layer.succeed(PublicRender.PublicRender, {
Expand All @@ -95,6 +102,9 @@ const fixture = Effect.fnUntraced(function* (
const layer = McpHttpServer.layerWebResearchToolkit.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(reader),
Layer.provide(Settings.layerTest()),
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provideMerge(runtime),
Layer.provide(NodeCrypto.layer),
);
Expand Down
Loading
Loading