History

Add hosted outbound HTTP policy

Route hosted plugin outbound requests through a shared Effect HttpClient layer that blocks local/private/metadata targets and re-checks redirects. Thread the executor-provided HTTP client through OpenAPI, GraphQL, MCP probing, and OAuth discovery, keep local harnesses on the normal fetch client, and split the restored security coverage into the relevant existing suites. Fix cloud migration scripts to invoke the local Drizzle binary under op run.

Rhys Sullivan committed 6 months ago

Commit 5fc67cd1 parent 745deac19 files · +559 −36Browse files

Unified · Split

19 files
  1. apps/cloud/executor.config.ts +7 −2
  2. apps/cloud/package.json +3 −3
  3. apps/cloud/src/mcp-session.e2e.node.test.ts +2 −0
  4. apps/cloud/src/services/__test-harness__/api-harness.ts +11 −2
  5. apps/cloud/src/services/executor.ts +9 −1
  6. apps/cloud/src/services/mcp-oauth.node.test.ts +63 −2
  7. apps/cloud/src/services/secrets-api.node.test.ts +6 −3
  8. apps/cloud/src/services/sources-api.node.test.ts +20 −0
  9. apps/cloud/src/services/tenant-isolation.node.test.ts +179 −5
  10. packages/core/sdk/src/executor.ts +6 −2
  11. packages/core/sdk/src/hosted-http-client.test.ts +69 −0
  12. packages/core/sdk/src/hosted-http-client.ts +135 −0
  13. packages/core/sdk/src/index.ts +7 −0
  14. packages/core/sdk/src/oauth-service.ts +8 −3
  15. packages/core/sdk/src/plugin.ts +2 −0
  16. packages/plugins/graphql/src/sdk/plugin.ts +6 −5
  17. packages/plugins/mcp/src/sdk/plugin.ts +17 −2
  18. packages/plugins/mcp/src/sdk/probe-shape.ts +3 −2
  19. packages/plugins/openapi/src/sdk/plugin.ts +6 −4
apps/cloud/executor.config.ts+7−2
@@ -37,10 +37,15 @@ interface CloudPluginDeps { · 36 unmodified lines
37 37
38export default defineExecutorConfig({38export default defineExecutorConfig({
39 dialect: "pg",39 dialect: "pg",
40 plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>40 plugins: ({
41 workosCredentials,
42 workosVaultClient,
43 }: CloudPluginDeps = {}) =>
41 [44 [
42 openApiPlugin(),45 openApiPlugin(),
43 mcpPlugin({ dangerouslyAllowStdioMCP: false }),46 mcpPlugin({
47 dangerouslyAllowStdioMCP: false,
48 }),
44 graphqlPlugin(),49 graphqlPlugin(),
45 workosVaultPlugin({50 workosVaultPlugin({
46 credentials: workosCredentials ?? { apiKey: "", clientId: "" },51 credentials: workosCredentials ?? { apiKey: "", clientId: "" },
apps/cloud/package.json+3−3
@@ -11,9 +11,9 @@ · 10 unmodified lines
11 "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts generate --config ./executor.config.ts --output ./src/services/executor-schema.ts",11 "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts generate --config ./executor.config.ts --output ./src/services/executor-schema.ts",
12 "db:generate": "drizzle-kit generate",12 "db:generate": "drizzle-kit generate",
13 "db:studio": "drizzle-kit studio",13 "db:studio": "drizzle-kit studio",
14 "db:studio:prod": "op run --env-file=.env.production -- drizzle-kit studio",14 "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio",
15 "db:migrate:prod": "op run --env-file=.env.production -- drizzle-kit migrate",15 "db:migrate:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
16 "db:migrate:dev": "op run --env-file=.env.op -- drizzle-kit migrate",16 "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
17 "build": "node scripts/build.mjs",17 "build": "node scripts/build.mjs",
18 "preview": "vite preview",18 "preview": "vite preview",
19 "deploy": "op run --env-file=.env.production -- bun run build && op run --env-file=.env.production -- wrangler deploy",19 "deploy": "op run --env-file=.env.production -- bun run build && op run --env-file=.env.production -- wrangler deploy",
apps/cloud/src/mcp-session.e2e.node.test.ts+2−0
@@ -33,6 +33,7 @@ import { · 32 unmodified lines
33 createExecutor,33 createExecutor,
34 definePlugin,34 definePlugin,
35} from "@executor-js/sdk";35} from "@executor-js/sdk";
36import { FetchHttpClient } from "effect/unstable/http";
36import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";37import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
37import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";38import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
38import executorConfig from "../executor.config";39import executorConfig from "../executor.config";
@@ -117,6 +118,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO · 78 unmodified lines
117 adapter,118 adapter,
118 blobs,119 blobs,
119 plugins,120 plugins,
121 httpClientLayer: FetchHttpClient.layer,
120 onElicitation: "accept-all",122 onElicitation: "accept-all",
121 });123 });
122 });124 });
apps/cloud/src/services/__test-harness__/api-harness.ts+11−2
@@ -26,7 +26,12 @@ import { · 25 unmodified lines
26} from "@executor-js/api/server";26} from "@executor-js/api/server";
27import { createExecutionEngine } from "@executor-js/execution";27import { createExecutionEngine } from "@executor-js/execution";
28import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";28import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
29import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";29import {
30 Scope,
31 ScopeId,
32 collectSchemas,
33 createExecutor,
34} from "@executor-js/sdk";
30import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";35import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
31import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";36import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
32 37
@@ -61,7 +66,10 @@ const defaultUserFor = (orgId: string) => `default_user_${orgId}`; · 28 unmodified lines
61// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
62 67
63const fakeVault = makeTestWorkOSVaultClient();68const fakeVault = makeTestWorkOSVaultClient();
64const testPlugins = executorConfig.plugins({ workosVaultClient: fakeVault });69const testPlugins = executorConfig.plugins({
70 workosVaultClient: fakeVault,
71});
72const testHttpClientLayer = FetchHttpClient.layer;
65 73
66const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) =>74const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) =>
67 Effect.gen(function* () {75 Effect.gen(function* () {
@@ -85,6 +93,7 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string · 17 unmodified lines
85 adapter,93 adapter,
86 blobs,94 blobs,
87 plugins,95 plugins,
96 httpClientLayer: testHttpClientLayer,
88 onElicitation: "accept-all",97 onElicitation: "accept-all",
89 });98 });
90 });99 });
apps/cloud/src/services/executor.ts+9−1
@@ -10,7 +10,13 @@ · 9 unmodified lines
10 10
11import { Effect } from "effect";11import { Effect } from "effect";
12 12
13import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";13import {
14 Scope,
15 ScopeId,
16 collectSchemas,
17 createExecutor,
18 makeHostedHttpClientLayer,
19} from "@executor-js/sdk";
14import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";20import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
15 21
16import { env } from "cloudflare:workers";22import { env } from "cloudflare:workers";
@@ -58,6 +64,7 @@ export const createScopedExecutor = ( · 41 unmodified lines
58 const { db } = yield* DbService;64 const { db } = yield* DbService;
59 65
60 const plugins = orgPlugins();66 const plugins = orgPlugins();
67 const httpClientLayer = makeHostedHttpClientLayer();
61 const schema = collectSchemas(plugins);68 const schema = collectSchemas(plugins);
62 const adapter = makePostgresAdapter({ db, schema });69 const adapter = makePostgresAdapter({ db, schema });
63 const blobs = makePostgresBlobStore({ db });70 const blobs = makePostgresBlobStore({ db });
@@ -82,6 +89,7 @@ export const createScopedExecutor = ( · 18 unmodified lines
82 adapter,89 adapter,
83 blobs,90 blobs,
84 plugins,91 plugins,
92 httpClientLayer,
85 onElicitation: "accept-all",93 onElicitation: "accept-all",
86 });94 });
87 });95 });
apps/cloud/src/services/mcp-oauth.node.test.ts+63−2
@@ -27,10 +27,10 @@ import { createServer, type Server } from "node:http"; · 26 unmodified lines
27import type { AddressInfo } from "node:net";27import type { AddressInfo } from "node:net";
28import { createHash, randomBytes } from "node:crypto";28import { createHash, randomBytes } from "node:crypto";
29 29
30import { Effect, Option, Schema } from "effect";30import { Effect, Option, Result, Schema } from "effect";
31import { ScopeId } from "@executor-js/sdk";31import { ScopeId } from "@executor-js/sdk";
32 32
33import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";33import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
34 34
35// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
36// Fake OAuth + MCP server36// Fake OAuth + MCP server
@@ -262,6 +262,67 @@ afterAll(async () => { · 225 unmodified lines
262// ---------------------------------------------------------------------------262// ---------------------------------------------------------------------------
263 263
264describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {264describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {
265 it.effect(
266 "start rejects a redirectUrl on a different origin before discovery",
267 () =>
268 Effect.gen(function* () {
269 const org = `org_${crypto.randomUUID()}`;
270 const scopeId = ScopeId.make(org);
271 const start = Date.now();
272
273 const result = yield* asOrg(org, (client) =>
274 client.oauth.start({
275 params: { scopeId },
276 payload: {
277 endpoint: "https://example.test/api",
278 redirectUrl: "https://other.example/cb",
279 connectionId: "conn-foreign-redirect",
280 tokenScope: String(scopeId),
281 pluginId: "mcp",
282 strategy: { kind: "dynamic-dcr" },
283 },
284 }),
285 ).pipe(Effect.result);
286
287 expect(Result.isFailure(result)).toBe(true);
288 expect(Date.now() - start).toBeLessThan(1000);
289 }),
290 { timeout: 30_000 },
291 );
292
293 it.effect(
294 "start rejects non-http redirectUrl schemes",
295 () =>
296 Effect.gen(function* () {
297 const org = `org_${crypto.randomUUID()}`;
298 const scopeId = ScopeId.make(org);
299
300 for (const redirectUrl of [
301 "javascript:alert(1)",
302 "data:text/html,<script>alert(1)</script>",
303 ]) {
304 const start = Date.now();
305 const result = yield* asOrg(org, (client) =>
306 client.oauth.start({
307 params: { scopeId },
308 payload: {
309 endpoint: "https://example.test/api",
310 redirectUrl,
311 connectionId: `conn-${crypto.randomUUID().slice(0, 8)}`,
312 tokenScope: String(scopeId),
313 pluginId: "mcp",
314 strategy: { kind: "dynamic-dcr" },
315 },
316 }),
317 ).pipe(Effect.result);
318
319 expect(Result.isFailure(result)).toBe(true);
320 expect(Date.now() - start).toBeLessThan(1000);
321 }
322 }),
323 { timeout: 30_000 },
324 );
325
265 it.effect(326 it.effect(
266 "startOAuth → authorize → completeOAuth writes tokens at the invoker scope",327 "startOAuth → authorize → completeOAuth writes tokens at the invoker scope",
267 () =>328 () =>
apps/cloud/src/services/secrets-api.node.test.ts+6−3
@@ -9,24 +9,27 @@ import { ScopeId, SecretId } from "@executor-js/sdk"; · 8 unmodified lines
9import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness";9import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness";
10 10
11describe("secrets api (HTTP)", () => {11describe("secrets api (HTTP)", () => {
12 it.effect("set → list → status round-trips a new secret without exposing plaintext", () =>12 it.effect("set → list → status returns secret metadata", () =>
13 Effect.gen(function* () {13 Effect.gen(function* () {
14 const org = `org_${crypto.randomUUID()}`;14 const org = `org_${crypto.randomUUID()}`;
15 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;15 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;
16 16
17 const secretValue = "sk-test-abc";
17 const setRef = yield* asOrg(org, (client) =>18 const setRef = yield* asOrg(org, (client) =>
18 client.secrets.set({19 client.secrets.set({
19 params: { scopeId: ScopeId.make(org) },20 params: { scopeId: ScopeId.make(org) },
20 payload: { id: SecretId.make(id), name: "My API Token", value: "sk-test-abc" },21 payload: { id: SecretId.make(id), name: "My API Token", value: secretValue },
21 }),22 }),
22 );23 );
23 expect(setRef.id).toBe(id);24 expect(setRef.id).toBe(id);
24 expect(setRef.scopeId).toBe(org);25 expect(setRef.scopeId).toBe(org);
26 expect(JSON.stringify(setRef)).not.toContain(secretValue);
25 27
26 const list = yield* asOrg(org, (client) =>28 const list = yield* asOrg(org, (client) =>
27 client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),29 client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),
28 );30 );
29 expect(list.find((s) => s.id === id)?.name).toBe("My API Token");31 expect(list.find((s) => s.id === id)?.name).toBe("My API Token");
32 expect(JSON.stringify(list)).not.toContain(secretValue);
30 33
31 const status = yield* asOrg(org, (client) =>34 const status = yield* asOrg(org, (client) =>
32 client.secrets.status({35 client.secrets.status({
@@ -37,7 +40,7 @@ describe("secrets api (HTTP)", () => { · 4 unmodified lines
37 }),40 }),
38 );41 );
39 42
40 it.effect("does not expose a plaintext resolve endpoint", () =>43 it.effect("resolve is not available through the public API", () =>
41 Effect.gen(function* () {44 Effect.gen(function* () {
42 const org = `org_${crypto.randomUUID()}`;45 const org = `org_${crypto.randomUUID()}`;
43 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;46 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;
apps/cloud/src/services/sources-api.node.test.ts+20−0
@@ -373,6 +373,26 @@ describe("sources api (HTTP)", () => { · 372 unmodified lines
373 }),373 }),
374 );374 );
375 375
376 it.effect("openapi.addSpec accepts a public HTTP baseUrl in local mode", () =>
377 Effect.gen(function* () {
378 const org = `org_${crypto.randomUUID()}`;
379
380 const result = yield* asOrg(org, (client) =>
381 client.openapi.addSpec({
382 params: { scopeId: ScopeId.make(org) },
383 payload: {
384 targetScope: ScopeId.make(org),
385 spec: MINIMAL_OPENAPI_SPEC,
386 namespace: `ns_${crypto.randomUUID().replace(/-/g, "_")}`,
387 baseUrl: "http://example.com",
388 },
389 }),
390 );
391
392 expect(result.toolCount).toBe(1);
393 }),
394 );
395
376 it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () =>396 it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () =>
377 Effect.gen(function* () {397 Effect.gen(function* () {
378 const server = yield* Effect.acquireRelease(398 const server = yield* Effect.acquireRelease(
apps/cloud/src/services/tenant-isolation.node.test.ts+179−5
@@ -3,9 +3,9 @@ · 2 unmodified lines
3// on the full cloud module graph.3// on the full cloud module graph.
4 4
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import { Effect } from "effect";6import { Effect, Result } from "effect";
7 7
8import { ScopeId, SecretId } from "@executor-js/sdk";8import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk";
9 9
10import { asOrg } from "./__test-harness__/api-harness";10import { asOrg } from "./__test-harness__/api-harness";
11 11
@@ -23,7 +23,80 @@ const MINIMAL_OPENAPI_SPEC = JSON.stringify({ · 11 unmodified lines
23});23});
24 24
25describe("tenant isolation (HTTP)", () => {25describe("tenant isolation (HTTP)", () => {
26 it.effect("sources.list does not leak across orgs", () =>26 it.effect("write requests cannot target another org scope", () =>
27 Effect.gen(function* () {
28 const orgA = `org_${crypto.randomUUID()}`;
29 const orgB = `org_${crypto.randomUUID()}`;
30
31 const result = yield* asOrg(orgB, (client) =>
32 client.secrets.set({
33 params: { scopeId: ScopeId.make(orgA) },
34 payload: {
35 id: SecretId.make("planted"),
36 name: "planted-by-org-b",
37 value: "should-be-rejected",
38 },
39 }),
40 ).pipe(Effect.result);
41
42 expect(Result.isFailure(result)).toBe(true);
43
44 const orgASecrets = yield* asOrg(orgA, (client) =>
45 client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }),
46 );
47 expect(orgASecrets.map((s) => s.id)).not.toContain("planted");
48 }),
49 );
50
51 it.effect("read requests with another org scope still use the caller stack", () =>
52 Effect.gen(function* () {
53 const orgA = `org_${crypto.randomUUID()}`;
54 const orgB = `org_${crypto.randomUUID()}`;
55 const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`;
56
57 yield* asOrg(orgA, (client) =>
58 client.secrets.set({
59 params: { scopeId: ScopeId.make(orgA) },
60 payload: { id: SecretId.make(idA), name: "org-a only", value: "v" },
61 }),
62 );
63
64 const result = yield* asOrg(orgB, (client) =>
65 client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }),
66 );
67 expect(result.map((s) => s.id)).not.toContain(idA);
68 }),
69 );
70
71 it.effect("delete requests cannot remove another org secret", () =>
72 Effect.gen(function* () {
73 const orgA = `org_${crypto.randomUUID()}`;
74 const orgB = `org_${crypto.randomUUID()}`;
75 const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`;
76
77 yield* asOrg(orgA, (client) =>
78 client.secrets.set({
79 params: { scopeId: ScopeId.make(orgA) },
80 payload: { id: SecretId.make(idA), name: "org-a only", value: "v" },
81 }),
82 );
83
84 yield* asOrg(orgB, (client) =>
85 client.secrets.remove({
86 params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) },
87 }),
88 ).pipe(Effect.result);
89
90 const status = yield* asOrg(orgA, (client) =>
91 client.secrets.status({
92 params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) },
93 }),
94 );
95 expect(status.status).toBe("resolved");
96 }),
97 );
98
99 it.effect("sources.list is scoped to the caller org", () =>
27 Effect.gen(function* () {100 Effect.gen(function* () {
28 const orgA = `org_${crypto.randomUUID()}`;101 const orgA = `org_${crypto.randomUUID()}`;
29 const orgB = `org_${crypto.randomUUID()}`;102 const orgB = `org_${crypto.randomUUID()}`;
@@ -47,7 +120,7 @@ describe("tenant isolation (HTTP)", () => { · 17 unmodified lines
47 }),120 }),
48 );121 );
49 122
50 it.effect("tools.list does not leak across orgs", () =>123 it.effect("tools.list is scoped to the caller org", () =>
51 Effect.gen(function* () {124 Effect.gen(function* () {
52 const orgA = `org_${crypto.randomUUID()}`;125 const orgA = `org_${crypto.randomUUID()}`;
53 const orgB = `org_${crypto.randomUUID()}`;126 const orgB = `org_${crypto.randomUUID()}`;
@@ -68,6 +141,9 @@ describe("tenant isolation (HTTP)", () => { · 14 unmodified lines
68 client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }),141 client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }),
69 );142 );
70 expect(orgBTools.map((t) => t.sourceId)).not.toContain(namespaceA);143 expect(orgBTools.map((t) => t.sourceId)).not.toContain(namespaceA);
144 for (const id of orgBTools.map((t) => t.id)) {
145 expect(id).not.toContain(namespaceA);
146 }
71 }),147 }),
72 );148 );
73 149
@@ -98,7 +174,7 @@ describe("tenant isolation (HTTP)", () => { · 24 unmodified lines
98 }),174 }),
99 );175 );
100 176
101 it.effect("secrets.list does not leak across orgs", () =>177 it.effect("secrets.list is scoped to the caller org", () =>
102 Effect.gen(function* () {178 Effect.gen(function* () {
103 const orgA = `org_${crypto.randomUUID()}`;179 const orgA = `org_${crypto.randomUUID()}`;
104 const orgB = `org_${crypto.randomUUID()}`;180 const orgB = `org_${crypto.randomUUID()}`;
@@ -168,6 +244,104 @@ describe("tenant isolation (HTTP)", () => { · 63 unmodified lines
168 }),244 }),
169 );245 );
170 246
247 it.effect("secret usages are scoped to the caller stack", () =>
248 Effect.gen(function* () {
249 const orgA = `org_${crypto.randomUUID()}`;
250 const orgB = `org_${crypto.randomUUID()}`;
251 const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`;
252 const secretIdA = SecretId.make(`sec_a_${crypto.randomUUID().slice(0, 8)}`);
253
254 yield* asOrg(orgA, (client) =>
255 Effect.gen(function* () {
256 yield* client.secrets.set({
257 params: { scopeId: ScopeId.make(orgA) },
258 payload: { id: secretIdA, name: "org-a token", value: "v" },
259 });
260 yield* client.openapi.addSpec({
261 params: { scopeId: ScopeId.make(orgA) },
262 payload: {
263 targetScope: ScopeId.make(orgA),
264 spec: MINIMAL_OPENAPI_SPEC,
265 namespace: namespaceA,
266 headers: {
267 Authorization: {
268 kind: "binding",
269 slot: "auth:token",
270 prefix: "Bearer ",
271 },
272 },
273 },
274 });
275 yield* client.openapi.setSourceBinding({
276 params: { scopeId: ScopeId.make(orgA) },
277 payload: {
278 sourceId: namespaceA,
279 sourceScope: ScopeId.make(orgA),
280 scope: ScopeId.make(orgA),
281 slot: "auth:token",
282 value: { kind: "secret", secretId: secretIdA },
283 },
284 });
285 }),
286 );
287
288 const usages = yield* asOrg(orgB, (client) =>
289 client.secrets.usages({
290 params: { scopeId: ScopeId.make(orgB), secretId: secretIdA },
291 }),
292 );
293
294 expect(usages).toEqual([]);
295 }),
296 );
297
298 it.effect("connection usages are scoped to the caller stack", () =>
299 Effect.gen(function* () {
300 const orgA = `org_${crypto.randomUUID()}`;
301 const orgB = `org_${crypto.randomUUID()}`;
302 const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`;
303 const connectionIdA = ConnectionId.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`);
304
305 yield* asOrg(orgA, (client) =>
306 Effect.gen(function* () {
307 yield* client.openapi.addSpec({
308 params: { scopeId: ScopeId.make(orgA) },
309 payload: {
310 targetScope: ScopeId.make(orgA),
311 spec: MINIMAL_OPENAPI_SPEC,
312 namespace: namespaceA,
313 headers: {
314 Authorization: {
315 kind: "binding",
316 slot: "auth:conn",
317 prefix: "Bearer ",
318 },
319 },
320 },
321 });
322 yield* client.openapi.setSourceBinding({
323 params: { scopeId: ScopeId.make(orgA) },
324 payload: {
325 sourceId: namespaceA,
326 sourceScope: ScopeId.make(orgA),
327 scope: ScopeId.make(orgA),
328 slot: "auth:conn",
329 value: { kind: "connection", connectionId: connectionIdA },
330 },
331 });
332 }),
333 ).pipe(Effect.result);
334
335 const usages = yield* asOrg(orgB, (client) =>
336 client.connections.usages({
337 params: { scopeId: ScopeId.make(orgB), connectionId: connectionIdA },
338 }),
339 ).pipe(Effect.result);
340
341 if (Result.isSuccess(usages)) expect(usages.success).toEqual([]);
342 }),
343 );
344
171 it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () =>345 it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () =>
172 Effect.gen(function* () {346 Effect.gen(function* () {
173 const orgA = `org_${crypto.randomUUID()}`;347 const orgA = `org_${crypto.randomUUID()}`;
packages/core/sdk/src/executor.ts+6−2
@@ -1,4 +1,5 @@
1import { Context, Deferred, Effect, Option, Result, Schema, Semaphore } from "effect";1import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect";
2import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
2import { generateKeyBetween } from "fractional-indexing";3import { generateKeyBetween } from "fractional-indexing";
3import {4import {
4 StorageError,5 StorageError,
@@ -341,6 +342,7 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> { · 336 unmodified lines
341 * an options arg.342 * an options arg.
342 */343 */
343 readonly onElicitation: OnElicitation;344 readonly onElicitation: OnElicitation;
345 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
344}346}
345 347
346// ---------------------------------------------------------------------------348// ---------------------------------------------------------------------------
@@ -2456,7 +2458,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2109 unmodified lines
2456 usagesForConnection: credentialBindingUsagesForConnection,2458 usagesForConnection: credentialBindingUsagesForConnection,
2457 };2459 };
2458 2460
2459 const oauthBundle = makeOAuth2Service({2461 const oauthBundle = makeOAuth2Service({
2460 adapter: core,2462 adapter: core,
2461 rawAdapter: adapter,2463 rawAdapter: adapter,
2462 secretsGet: (id) =>2464 secretsGet: (id) =>
@@ -2465,6 +2467,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2 unmodified lines
2465 ),2467 ),
2466 secretsSet: (input) => secretsSet(input),2468 secretsSet: (input) => secretsSet(input),
2467 connectionsCreate: (input) => connectionsCreate(input),2469 connectionsCreate: (input) => connectionsCreate(input),
2470 httpClientLayer: config.httpClientLayer,
2468 });2471 });
2469 connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider);2472 connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider);
2470 2473
@@ -2502,6 +2505,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 31 unmodified lines
2502 const ctx: PluginCtx<unknown> = {2505 const ctx: PluginCtx<unknown> = {
2503 scopes,2506 scopes,
2504 storage,2507 storage,
2508 httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer,
2505 core: {2509 core: {
2506 sources: {2510 sources: {
2507 register: (input: SourceInput) =>2511 register: (input: SourceInput) =>
packages/core/sdk/src/hosted-http-client.test.ts+69−0
@@ -0,0 +1,69 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Result } from "effect";
3import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4
5import {
6 HostedOutboundRequestBlocked,
7 makeHostedHttpClientLayer,
8 validateHostedOutboundUrl,
9} from "./hosted-http-client";
10
11describe("hosted outbound HTTP client", () => {
12 it.effect("allows public HTTP and HTTPS URLs", () =>
13 Effect.gen(function* () {
14 yield* validateHostedOutboundUrl("https://example.com/openapi.json");
15 yield* validateHostedOutboundUrl("http://example.com/graphql");
16 }),
17 );
18
19 it.effect("rejects local and private network URLs", () =>
20 Effect.gen(function* () {
21 for (const url of [
22 "http://localhost:3000",
23 "http://127.0.0.1:3000",
24 "http://10.0.0.1/openapi.json",
25 "http://172.16.0.1/graphql",
26 "http://192.168.1.10/mcp",
27 "http://169.254.169.254/latest/meta-data/",
28 ]) {
29 const result = yield* validateHostedOutboundUrl(url).pipe(Effect.result);
30 expect(Result.isFailure(result)).toBe(true);
31 if (Result.isFailure(result)) {
32 expect(result.failure).toBeInstanceOf(HostedOutboundRequestBlocked);
33 }
34 }
35 }),
36 );
37
38 it.effect("can allow local network URLs explicitly", () =>
39 Effect.gen(function* () {
40 yield* validateHostedOutboundUrl("http://127.0.0.1:3000", {
41 allowLocalNetwork: true,
42 });
43 }),
44 );
45
46 it.effect("checks redirected URLs before following them", () =>
47 Effect.gen(function* () {
48 let calls = 0;
49 const fakeFetch: typeof globalThis.fetch = (async (input) => {
50 calls++;
51 const url = input instanceof Request ? input.url : String(input);
52 if (url === "https://public.example/start") {
53 return new Response(null, {
54 status: 302,
55 headers: { location: "http://127.0.0.1:3000/internal" },
56 });
57 }
58 return new Response("unexpected", { status: 200 });
59 }) as typeof globalThis.fetch;
60 const result = yield* Effect.gen(function* () {
61 const client = yield* HttpClient.HttpClient;
62 return yield* client.execute(HttpClientRequest.get("https://public.example/start"));
63 }).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })), Effect.result);
64
65 expect(Result.isFailure(result)).toBe(true);
66 expect(calls).toBe(1);
67 }),
68 );
69});
packages/core/sdk/src/hosted-http-client.ts+135−0
@@ -0,0 +1,135 @@
1import { Effect, Layer, Schema } from "effect";
2import { FetchHttpClient, HttpClient } from "effect/unstable/http";
3
4export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<HostedOutboundRequestBlocked>()(
5 "HostedOutboundRequestBlocked",
6 {
7 url: Schema.String,
8 reason: Schema.String,
9 },
10) {}
11
12export interface HostedHttpClientOptions {
13 readonly allowLocalNetwork?: boolean;
14 readonly maxRedirects?: number;
15 readonly fetch?: typeof globalThis.fetch;
16}
17
18const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
19 const parts = hostname.split(".");
20 if (parts.length !== 4) return null;
21 const parsed: number[] = [];
22 for (const part of parts) {
23 if (!/^\d+$/.test(part)) return null;
24 const value = Number(part);
25 if (!Number.isInteger(value) || value < 0 || value > 255) return null;
26 parsed.push(value);
27 }
28 return parsed as [number, number, number, number];
29};
30
31const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>
32 a === 0 ||
33 a === 10 ||
34 a === 127 ||
35 (a === 169 && b === 254) ||
36 (a === 172 && b >= 16 && b <= 31) ||
37 (a === 192 && b === 168);
38
39const isBlockedMetadataHostname = (hostname: string): boolean => {
40 const normalized = hostname.toLowerCase();
41 return (
42 normalized === "metadata.google.internal" ||
43 normalized === "metadata" ||
44 normalized === "instance-data" ||
45 normalized === "169.254.169.254"
46 );
47};
48
49const isLocalOrPrivateHostname = (hostname: string): boolean => {
50 const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
51 if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;
52 const ipv4 = parseIpv4(normalized);
53 if (ipv4) return isPrivateIpv4(ipv4);
54 return (
55 normalized === "::1" ||
56 normalized.startsWith("fe80:") ||
57 normalized.startsWith("fc") ||
58 normalized.startsWith("fd")
59 );
60};
61
62export const validateHostedOutboundUrl = (
63 value: string,
64 options: HostedHttpClientOptions = {},
65): Effect.Effect<void, HostedOutboundRequestBlocked> =>
66 Effect.gen(function* () {
67 const url = yield* Effect.try({
68 try: () => new URL(value),
69 catch: () =>
70 new HostedOutboundRequestBlocked({
71 url: value,
72 reason: "URL is invalid",
73 }),
74 });
75
76 if (url.protocol !== "http:" && url.protocol !== "https:") {
77 return yield* new HostedOutboundRequestBlocked({
78 url: value,
79 reason: "Only HTTP and HTTPS outbound requests are allowed",
80 });
81 }
82
83 if (isBlockedMetadataHostname(url.hostname)) {
84 return yield* new HostedOutboundRequestBlocked({
85 url: value,
86 reason: "Metadata service addresses are not allowed",
87 });
88 }
89
90 if (!options.allowLocalNetwork && isLocalOrPrivateHostname(url.hostname)) {
91 return yield* new HostedOutboundRequestBlocked({
92 url: value,
93 reason: "Local and private network addresses are not allowed",
94 });
95 }
96 });
97
98const guardFetch = (
99 fetch: typeof globalThis.fetch,
100 options: HostedHttpClientOptions,
101): typeof globalThis.fetch =>
102 (async (input, init) => {
103 const maxRedirects = options.maxRedirects ?? 10;
104 let current: Parameters<typeof globalThis.fetch>[0] | URL = input;
105 for (let redirects = 0; redirects <= maxRedirects; redirects++) {
106 const url = current instanceof Request ? current.url : String(current);
107 Effect.runSync(validateHostedOutboundUrl(url, options));
108 const response = await fetch(current, { ...init, redirect: "manual" });
109 if (
110 response.status >= 300 &&
111 response.status < 400 &&
112 response.headers.has("location") &&
113 redirects < maxRedirects
114 ) {
115 current = new URL(response.headers.get("location")!, url).toString();
116 continue;
117 }
118 return response;
119 }
120 return fetch(current, { ...init, redirect: "manual" });
121 }) as typeof globalThis.fetch;
122
123export const makeHostedHttpClientLayer = (
124 options: HostedHttpClientOptions = {},
125): Layer.Layer<HttpClient.HttpClient> =>
126 FetchHttpClient.layer.pipe(
127 Layer.provide(
128 options.fetch
129 ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options))
130 : Layer.effect(
131 FetchHttpClient.Fetch,
132 Effect.map(Effect.service(FetchHttpClient.Fetch), (fetch) => guardFetch(fetch, options)),
133 ),
134 ),
135 );
packages/core/sdk/src/index.ts+7−0
@@ -232,6 +232,13 @@ export { · 231 unmodified lines
232export { makeOAuth2Service, type OAuthServiceDeps } from "./oauth-service";232export { makeOAuth2Service, type OAuthServiceDeps } from "./oauth-service";
233export type { ScopedDBAdapter, ScopedTypedAdapter } from "./scoped-adapter";233export type { ScopedDBAdapter, ScopedTypedAdapter } from "./scoped-adapter";
234 234
235export {
236 HostedOutboundRequestBlocked,
237 makeHostedHttpClientLayer,
238 validateHostedOutboundUrl,
239 type HostedHttpClientOptions,
240} from "./hosted-http-client";
241
235export {242export {
236 OAuthDiscoveryError,243 OAuthDiscoveryError,
237 OAuthAuthorizationServerMetadataSchema,244 OAuthAuthorizationServerMetadataSchema,
packages/core/sdk/src/oauth-service.ts+8−3
@@ -35,7 +35,7 @@ · 34 unmodified lines
35// every strategy because refresh semantics are strategy-independent.35// every strategy because refresh semantics are strategy-independent.
36// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
37 37
38import { Duration, Effect, Option, Schema } from "effect";38import { Duration, Effect, Layer, Option, Schema } from "effect";
39import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";39import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
40 40
41import type { StorageFailure, TypedAdapter } from "@executor-js/storage-core";41import type { StorageFailure, TypedAdapter } from "@executor-js/storage-core";
@@ -183,6 +183,8 @@ export interface OAuthServiceDeps { · 141 unmodified lines
183 readonly newSessionId?: () => string;183 readonly newSessionId?: () => string;
184 /** `Date.now()` substitute — tests override to drive TTL behavior. */184 /** `Date.now()` substitute — tests override to drive TTL behavior. */
185 readonly now?: () => number;185 readonly now?: () => number;
186 /** Outbound HTTP client used for OAuth metadata/DCR probes. */
187 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
186}188}
187 189
188const defaultSessionId = (): string => {190const defaultSessionId = (): string => {
@@ -225,6 +227,7 @@ export const makeOAuth2Service = ( · 36 unmodified lines
225): { readonly service: OAuthService; readonly connectionProvider: ConnectionProvider } => {227): { readonly service: OAuthService; readonly connectionProvider: ConnectionProvider } => {
226 const now = deps.now ?? (() => Date.now());228 const now = deps.now ?? (() => Date.now());
227 const newSessionId = deps.newSessionId ?? defaultSessionId;229 const newSessionId = deps.newSessionId ?? defaultSessionId;
230 const httpClientLayer = deps.httpClientLayer;
228 231
229 // -------------------------------------------------------------------232 // -------------------------------------------------------------------
230 // probe233 // probe
@@ -232,6 +235,7 @@ export const makeOAuth2Service = ( · 1 unmodified line
232 const probe = (input: OAuthProbeInput): Effect.Effect<OAuthProbeResult, OAuthProbeError> =>235 const probe = (input: OAuthProbeInput): Effect.Effect<OAuthProbeResult, OAuthProbeError> =>
233 Effect.gen(function* () {236 Effect.gen(function* () {
234 const resource = yield* discoverProtectedResourceMetadata(input.endpoint, {237 const resource = yield* discoverProtectedResourceMetadata(input.endpoint, {
238 httpClientLayer,
235 resourceHeaders: input.headers,239 resourceHeaders: input.headers,
236 resourceQueryParams: input.queryParams,240 resourceQueryParams: input.queryParams,
237 }).pipe(241 }).pipe(
@@ -257,7 +261,7 @@ export const makeOAuth2Service = ( · 19 unmodified lines
257 })();261 })();
258 262
259 const authServer = authorizationServerUrl263 const authServer = authorizationServerUrl
260 ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl).pipe(264 ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl, { httpClientLayer }).pipe(
261 Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)),265 Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)),
262 )266 )
263 : null;267 : null;
@@ -312,7 +316,7 @@ export const makeOAuth2Service = ( · 48 unmodified lines
312 response.headers["www-authenticate"] ?? response.headers["WWW-Authenticate"];316 response.headers["www-authenticate"] ?? response.headers["WWW-Authenticate"];
313 return !!wwwAuth && /^\s*bearer\b/i.test(wwwAuth);317 return !!wwwAuth && /^\s*bearer\b/i.test(wwwAuth);
314 }).pipe(318 }).pipe(
315 Effect.provide(FetchHttpClient.layer),319 Effect.provide(httpClientLayer ?? FetchHttpClient.layer),
316 Effect.catch(() => Effect.succeed(false)),320 Effect.catch(() => Effect.succeed(false)),
317 );321 );
318 322
@@ -344,6 +348,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines
344 scopes: strategy.scopes,348 scopes: strategy.scopes,
345 },349 },
346 {350 {
351 httpClientLayer,
347 resourceHeaders: input.headers,352 resourceHeaders: input.headers,
348 resourceQueryParams: input.queryParams,353 resourceQueryParams: input.queryParams,
349 },354 },
packages/core/sdk/src/plugin.ts+2−0
@@ -1,4 +1,5 @@
1import type { Context, Effect, Layer } from "effect";1import type { Context, Effect, Layer } from "effect";
2import type { HttpClient } from "effect/unstable/http";
2import type { HttpApiGroup } from "effect/unstable/httpapi";3import type { HttpApiGroup } from "effect/unstable/httpapi";
3import type { DBSchema, StorageFailure } from "@executor-js/storage-core";4import type { DBSchema, StorageFailure } from "@executor-js/storage-core";
4 5
@@ -102,6 +103,7 @@ export interface PluginCtx<TStore = unknown> { · 97 unmodified lines
102 */103 */
103 readonly scopes: readonly Scope[];104 readonly scopes: readonly Scope[];
104 readonly storage: TStore;105 readonly storage: TStore;
106 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
105 107
106 readonly core: {108 readonly core: {
107 readonly sources: {109 readonly sources: {
packages/plugins/graphql/src/sdk/plugin.ts+6−5
@@ -1,6 +1,6 @@
1import { Effect, Option, Schema } from "effect";1import { Effect, Option, Schema } from "effect";
2import type { Layer } from "effect";2import type { Layer } from "effect";
3import { FetchHttpClient, HttpClient } from "effect/unstable/http";3import { HttpClient } from "effect/unstable/http";
4 4
5import { GraphqlGroup } from "../api/group";5import { GraphqlGroup } from "../api/group";
6import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers";6import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers";
@@ -916,15 +916,14 @@ const makeGraphqlExtension = ( · 909 unmodified lines
916export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>;916export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>;
917 917
918export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {918export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {
919 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
920
921 return {919 return {
922 id: "graphql" as const,920 id: "graphql" as const,
923 packageName: "@executor-js/plugin-graphql",921 packageName: "@executor-js/plugin-graphql",
924 schema: graphqlSchema,922 schema: graphqlSchema,
925 storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),923 storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),
926 924
927 extension: (ctx) => makeGraphqlExtension(ctx, httpClientLayer, options?.configFile),925 extension: (ctx) =>
926 makeGraphqlExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, options?.configFile),
928 927
929 staticSources: (self) => [928 staticSources: (self) => [
930 {929 {
@@ -969,6 +968,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 38 unmodified lines
969 968
970 invokeTool: ({ ctx, toolRow, args }) =>969 invokeTool: ({ ctx, toolRow, args }) =>
971 Effect.gen(function* () {970 Effect.gen(function* () {
971 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
972 // toolRow.scope_id is the resolved owning scope of the tool972 // toolRow.scope_id is the resolved owning scope of the tool
973 // (innermost-wins from the executor's stack). The matching973 // (innermost-wins from the executor's stack). The matching
974 // graphql_operation + graphql_source rows live at the same974 // graphql_operation + graphql_source rows live at the same
@@ -1081,8 +1081,9 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 106 unmodified lines
1081 1081
1082 usagesForConnection: () => Effect.succeed([]),1082 usagesForConnection: () => Effect.succeed([]),
1083 1083
1084 detect: ({ url }) =>1084 detect: ({ ctx, url }) =>
1085 Effect.gen(function* () {1085 Effect.gen(function* () {
1086 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
1086 const trimmed = url.trim();1087 const trimmed = url.trim();
1087 if (!trimmed) return null;1088 if (!trimmed) return null;
1088 const parsed = yield* Effect.try({1089 const parsed = yield* Effect.try({
packages/plugins/mcp/src/sdk/plugin.ts+17−2
@@ -1,4 +1,15 @@
1import { Duration, Effect, Exit, Option, Predicate, Result, Scope, ScopedCache } from "effect";1import {
2 Duration,
3 Effect,
4 Exit,
5 Layer,
6 Option,
7 Predicate,
8 Result,
9 Scope,
10 ScopedCache,
11} from "effect";
12import type { HttpClient } from "effect/unstable/http";
2 13
3import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";14import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
4 15
@@ -848,6 +859,7 @@ export interface McpPluginOptions { · 843 unmodified lines
848 * `process.env`. Only enable for trusted single-user contexts.859 * `process.env`. Only enable for trusted single-user contexts.
849 */860 */
850 readonly dangerouslyAllowStdioMCP?: boolean;861 readonly dangerouslyAllowStdioMCP?: boolean;
862 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
851 /** If provided, source add/remove is mirrored to executor.jsonc863 /** If provided, source add/remove is mirrored to executor.jsonc
852 * (best-effort — file errors are logged, not raised). */864 * (best-effort — file errors are logged, not raised). */
853 readonly configFile?: ConfigFileSink;865 readonly configFile?: ConfigFileSink;
@@ -937,6 +949,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 83 unmodified lines
937 storage: (deps): McpBindingStore => makeMcpStore(deps),949 storage: (deps): McpBindingStore => makeMcpStore(deps),
938 950
939 extension: (ctx) => {951 extension: (ctx) => {
952 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
940 const probeEndpoint = (input: string | McpProbeEndpointInput) =>953 const probeEndpoint = (input: string | McpProbeEndpointInput) =>
941 Effect.gen(function* () {954 Effect.gen(function* () {
942 const endpoint = typeof input === "string" ? input : input.endpoint;955 const endpoint = typeof input === "string" ? input : input.endpoint;
@@ -994,6 +1007,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 51 unmodified lines
994 // probe and be misclassified as MCP. The shape probe rejects1007 // probe and be misclassified as MCP. The shape probe rejects
995 // anything whose initialize response isn't 2xx or 401+Bearer.1008 // anything whose initialize response isn't 2xx or 401+Bearer.
996 const shape = yield* probeMcpEndpointShape(trimmed, {1009 const shape = yield* probeMcpEndpointShape(trimmed, {
1010 httpClientLayer,
997 headers: probeHeaders,1011 headers: probeHeaders,
998 queryParams: probeQueryParams,1012 queryParams: probeQueryParams,
999 });1013 });
@@ -1575,6 +1589,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 575 unmodified lines
1575 1589
1576 detect: ({ ctx, url }) =>1590 detect: ({ ctx, url }) =>
1577 Effect.gen(function* () {1591 Effect.gen(function* () {
1592 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
1578 const trimmed = url.trim();1593 const trimmed = url.trim();
1579 if (!trimmed) return null;1594 if (!trimmed) return null;
1580 1595
@@ -1610,7 +1625,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 29 unmodified lines
1610 1625
1611 // host publishes RFC 9728 + 8414 metadata) would be classified1626 // host publishes RFC 9728 + 8414 metadata) would be classified
1612 // as MCP whenever the cross-plugin detector fans out to us.1627 // as MCP whenever the cross-plugin detector fans out to us.
1613 const shape = yield* probeMcpEndpointShape(trimmed);1628 const shape = yield* probeMcpEndpointShape(trimmed, { httpClientLayer });
1614 if (shape.kind !== "mcp") return null;1629 if (shape.kind !== "mcp") return null;
1615 1630
1616 // Confirm OAuth metadata is actually reachable. The shape1631 // Confirm OAuth metadata is actually reachable. The shape
packages/plugins/mcp/src/sdk/probe-shape.ts+3−2
@@ -30,7 +30,7 @@ · 29 unmodified lines
30// round-trip, no DCR — every non-MCP endpoint exits here.30// round-trip, no DCR — every non-MCP endpoint exits here.
31// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
32 32
33import { Data, Duration, Effect, Option, Schema } from "effect";33import { Data, Duration, Effect, Layer, Option, Schema } from "effect";
34import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";34import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
35 35
36/** MCP initialize request body used as the shape probe. Any real MCP36/** MCP initialize request body used as the shape probe. Any real MCP
@@ -97,6 +97,7 @@ export type McpShapeProbeResult = · 60 unmodified lines
97export interface ProbeOptions {97export interface ProbeOptions {
98 /** Abort the request after this many ms. Default 8000. */98 /** Abort the request after this many ms. Default 8000. */
99 readonly timeoutMs?: number;99 readonly timeoutMs?: number;
100 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
100 readonly headers?: Record<string, string>;101 readonly headers?: Record<string, string>;
101 readonly queryParams?: Record<string, string>;102 readonly queryParams?: Record<string, string>;
102}103}
@@ -191,7 +192,7 @@ export const probeMcpEndpointShape = ( · 88 unmodified lines
191 reason: `unexpected status ${postResponse.status} for initialize`,192 reason: `unexpected status ${postResponse.status} for initialize`,
192 } as const;193 } as const;
193 }).pipe(194 }).pipe(
194 Effect.provide(FetchHttpClient.layer),195 Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),
195 Effect.mapError(196 Effect.mapError(
196 (cause) =>197 (cause) =>
197 new ProbeTransportError({198 new ProbeTransportError({
packages/plugins/openapi/src/sdk/plugin.ts+6−4
@@ -1,6 +1,6 @@
1import { Effect, Option, Predicate, Schema } from "effect";1import { Effect, Option, Predicate, Schema } from "effect";
2import type { Layer } from "effect";2import type { Layer } from "effect";
3import { FetchHttpClient, HttpClient } from "effect/unstable/http";3import { HttpClient } from "effect/unstable/http";
4 4
5import { OpenApiGroup } from "../api/group";5import { OpenApiGroup } from "../api/group";
6import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers";6import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers";
@@ -754,8 +754,6 @@ const toOpenApiSourceConfig = ( · 747 unmodified lines
754const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://");754const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://");
755 755
756export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {756export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
757 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
758
759 type RebuildInput = {757 type RebuildInput = {
760 readonly specText: string;758 readonly specText: string;
761 readonly scope: string;759 readonly scope: string;
@@ -902,6 +900,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 140 unmodified lines
902 // than surface a 500 through the unwhitelisted error channel.900 // than surface a 500 through the unwhitelisted error channel.
903 const refreshSourceInternal = (ctx: PluginCtx<OpenapiStore>, sourceId: string, scope: string) =>901 const refreshSourceInternal = (ctx: PluginCtx<OpenapiStore>, sourceId: string, scope: string) =>
904 Effect.gen(function* () {902 Effect.gen(function* () {
903 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
905 const existing = yield* ctx.storage.getSource(sourceId, scope);904 const existing = yield* ctx.storage.getSource(sourceId, scope);
906 if (!existing) return;905 if (!existing) return;
907 const effective = yield* resolveEffectiveSourceConfig(ctx, existing);906 const effective = yield* resolveEffectiveSourceConfig(ctx, existing);
@@ -938,6 +937,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 30 unmodified lines
938 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),937 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
939 938
940 extension: (ctx) => {939 extension: (ctx) => {
940 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
941 const addSpecInternal = (config: OpenApiSpecConfig) =>941 const addSpecInternal = (config: OpenApiSpecConfig) =>
942 Effect.gen(function* () {942 Effect.gen(function* () {
943 // Resolve URL → text and parse BEFORE opening a transaction.943 // Resolve URL → text and parse BEFORE opening a transaction.
@@ -1173,6 +1173,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 229 unmodified lines
1173 1173
1174 invokeTool: ({ ctx, toolRow, args }) =>1174 invokeTool: ({ ctx, toolRow, args }) =>
1175 Effect.gen(function* () {1175 Effect.gen(function* () {
1176 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
1176 // toolRow.scope_id is the resolved owning scope of the tool1177 // toolRow.scope_id is the resolved owning scope of the tool
1177 // (innermost-wins from the executor's stack). The matching1178 // (innermost-wins from the executor's stack). The matching
1178 // openapi_operation + openapi_source rows live at the same1179 // openapi_operation + openapi_source rows live at the same
@@ -1313,8 +1314,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 134 unmodified lines
1313 // means stale UI state, which is worth surfacing to the caller.1314 // means stale UI state, which is worth surfacing to the caller.
1314 refreshSource: ({ ctx, sourceId, scope }) => refreshSourceInternal(ctx, sourceId, scope),1315 refreshSource: ({ ctx, sourceId, scope }) => refreshSourceInternal(ctx, sourceId, scope),
1315 1316
1316 detect: ({ url }) =>1317 detect: ({ ctx, url }) =>
1317 Effect.gen(function* () {1318 Effect.gen(function* () {
1319 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
1318 const trimmed = url.trim();1320 const trimmed = url.trim();
1319 if (!trimmed) return null;1321 if (!trimmed) return null;
1320 const parsed = yield* Effect.try({1322 const parsed = yield* Effect.try({