From c4601ab998d83f22b77de43a2bd89a744cfeb08f Mon Sep 17 00:00:00 2001 From: darjss Date: Sat, 8 Aug 2026 16:25:56 +0800 Subject: [PATCH 01/10] feat(host-cloudflare): add Cloudflare OS user bridge --- apps/host-cloudflare/README.md | 5 ++ .../src/auth/cloudflare-access.ts | 35 ++++++++ apps/host-cloudflare/src/config.ts | 9 ++ apps/host-cloudflare/src/worker.ts | 90 ++++++++++++++++++- apps/host-cloudflare/wrangler.jsonc | 2 +- packages/hosts/mcp/src/tool-server.ts | 1 + 6 files changed, 140 insertions(+), 2 deletions(-) diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 970057028..830b2e5b2 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -60,6 +60,11 @@ Now visiting the Worker prompts an Access login; the Worker validates the issued JWT on every request. Unauthenticated requests return 401. MCP clients present an Access JWT or `Cf-Access-Client-Id`/`-Secret` service-token headers. +A Cloudflare OS deployment can also set the same `CLOUDFLARE_OS_AUTH_SECRET` +secret on both Workers. Exempt only `/os/*` from the Access application: those +routes accept short-lived, signed per-user assertions and expose only Erxes +connection provisioning plus MCP. + The Access values are live Worker variables, not values in `wrangler.jsonc`. Wrangler's `keep_vars` option preserves them during later code deploys. Run the command above again whenever you need to change them. diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 7590245d4..506d8dbca 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -60,6 +60,9 @@ export const principalFromAccessClaims = ( */ export const makeAccessVerifier = (config: CloudflareConfig) => { const issuer = `https://${config.accessTeamDomain}`; + const cloudflareOsKey = config.cloudflareOsAuthSecret + ? new TextEncoder().encode(config.cloudflareOsAuthSecret) + : null; // Cached, lazily-fetched team signing keys; jose handles rotation + caching. const jwks = config.enableDevAuth ? null @@ -82,6 +85,38 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { const verify = (request: Request): Effect.Effect => Effect.gen(function* () { + if (cloudflareOsKey) { + const authorization = request.headers.get("Authorization"); + const token = authorization?.startsWith("Bearer ") ? authorization.slice(7) : null; + if (token) { + const verified = yield* Effect.tryPromise({ + try: () => + jwtVerify(token, cloudflareOsKey, { + issuer: "cloudflare-os", + audience: "executor", + algorithms: ["HS256"], + }), + catch: () => "invalid cloudflare os assertion", + }).pipe(Effect.orElseSucceed(() => null)); + const subject = verified?.payload.sub; + const organizationId = verified?.payload.org; + const email = typeof verified?.payload.email === "string" ? verified.payload.email : ""; + if (typeof subject === "string" && typeof organizationId === "string") { + return { + kind: "member", + accountId: subject, + organizationId, + organizationName: organizationId, + organizationSlug: config.organizationSlug, + email, + name: email || null, + avatarUrl: null, + roles: ["member"], + }; + } + } + } + if (config.enableDevAuth) return devPrincipal; if (!jwks) return null; const token = request.headers.get("Cf-Access-Jwt-Assertion"); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index c397c4ef8..03ba23860 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -47,6 +47,8 @@ export interface CloudflareEnv { readonly SELF_HOSTED_ORG_SLUG?: string; /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ readonly EXECUTOR_SECRET_KEY?: string; + /** Shared secret used to verify short-lived Cloudflare OS user assertions. */ + readonly CLOUDFLARE_OS_AUTH_SECRET?: string; readonly ALLOW_LOCAL_NETWORK?: string; readonly VITE_PUBLIC_SITE_URL?: string; /** @@ -69,6 +71,7 @@ export interface CloudflareConfig { /** URL slug for org-prefixed console paths (`//policies`). */ readonly organizationSlug: string; readonly secretKey: string; + readonly cloudflareOsAuthSecret?: string; readonly allowLocalNetwork: boolean; /** Explicit web base URL (`VITE_PUBLIC_SITE_URL`). Unset on a Worker with no * static URL — the per-request origin is used instead (see RequestWebOrigin). */ @@ -137,6 +140,11 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { "EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) — it encrypts stored secrets at rest in D1", ); } + const cloudflareOsAuthSecret = env.CLOUDFLARE_OS_AUTH_SECRET?.trim(); + if (cloudflareOsAuthSecret && cloudflareOsAuthSecret.length < 32) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: weak shared auth would let callers forge user identity + throw new Error("CLOUDFLARE_OS_AUTH_SECRET must be at least 32 characters when set"); + } const enableDevAuth = env.ENABLE_DEV_AUTH === "true"; const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); const accessAud = (env.ACCESS_AUD ?? "").trim(); @@ -165,6 +173,7 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { organizationName: env.SELF_HOSTED_ORG_NAME ?? "Default", organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG), secretKey, + cloudflareOsAuthSecret, allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", // Pinned origin via the shared resolver. A Worker receives no PaaS platform // vars (env: {} — there is nothing to detect), so only the explicit diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index ac9c1b30b..e68b39c81 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -40,6 +40,86 @@ const accessConfigErrorResponse = (missingVars: readonly string[]): Response => }, }); +const ERXES_INTEGRATION = "erxes-officenext"; + +const executorRequest = ( + original: Request, + path: string, + method: string, + body?: unknown, +): Request => { + const url = new URL(original.url); + url.pathname = path; + url.search = ""; + const headers = new Headers(); + const authorization = original.headers.get("Authorization"); + if (authorization) headers.set("Authorization", authorization); + if (body !== undefined) headers.set("Content-Type", "application/json"); + return new Request(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); +}; + +const provisionErxes = async ( + request: Request, + app: (request: Request) => Promise, +): Promise => { + if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); + + let input: { endpoint?: unknown; cookie?: unknown }; + try { + input = (await request.json()) as { endpoint?: unknown; cookie?: unknown }; + } catch { + return new Response("Invalid request", { status: 400 }); + } + if ( + typeof input.endpoint !== "string" || + typeof input.cookie !== "string" || + !input.cookie.startsWith("auth-token=") || + input.cookie.includes("\r") || + input.cookie.includes("\n") + ) { + return new Response("Invalid request", { status: 400 }); + } + + const existing = await app( + executorRequest(request, `/api/graphql/integrations/${ERXES_INTEGRATION}`, "GET"), + ); + if (!existing.ok) return existing; + if ((await existing.json()) === null) { + const created = await app( + executorRequest(request, "/api/graphql/integrations", "POST", { + endpoint: input.endpoint, + slug: ERXES_INTEGRATION, + name: "OfficeNext", + description: "OfficeNext Erxes GraphQL API", + authenticationTemplate: [ + { + slug: "cookie", + type: "apiKey", + headers: { Cookie: [{ type: "variable", name: "token" }] }, + }, + ], + }), + ); + if (!created.ok && created.status !== 409) return created; + } + + return app( + executorRequest(request, "/api/connections", "POST", { + owner: "user", + name: ERXES_INTEGRATION, + integration: ERXES_INTEGRATION, + template: "cookie", + value: input.cookie, + identityLabel: "OfficeNext", + description: "Your OfficeNext account", + }), + ); +}; + export default { fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { const missingAccessVars = missingCloudflareAccessVars(env); @@ -48,7 +128,15 @@ export default { } const serve = await resolveHandler(env); - if (new URL(request.url).pathname === "/mcp") { + const url = new URL(request.url); + if (url.pathname === "/os/mcp") { + url.pathname = "/mcp"; + return serve.mcp(new Request(url, request), env, ctx); + } + if (url.pathname === "/os/provision") { + return provisionErxes(request, serve.app); + } + if (url.pathname === "/mcp") { return serve.mcp(request, env, ctx); } return serve.app(request); diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index f97e19507..9259d50ce 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -20,7 +20,7 @@ // deployed Worker). "binding": "ASSETS", "not_found_handling": "single-page-application", - "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"], + "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/os/*", "/.well-known/*", "/v1", "/v1/*"], }, // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` // auto-provisions it on first deploy; replace database_id after that, or run diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 41c61bcd0..c0643c4a6 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1515,6 +1515,7 @@ export const createExecutorMcpServer = ( .optional() .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'), }, + annotations: { readOnlyHint: true }, }, ({ name }) => runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))), From c8dcf40b86cba7b56a640aaa21f2415cc4f845ae Mon Sep 17 00:00:00 2001 From: darjss Date: Sat, 8 Aug 2026 16:29:47 +0800 Subject: [PATCH 02/10] chore(host-cloudflare): document request parse boundary --- apps/host-cloudflare/src/worker.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index e68b39c81..ec8baeea0 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -69,6 +69,7 @@ const provisionErxes = async ( if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); let input: { endpoint?: unknown; cookie?: unknown }; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: malformed external JSON becomes a 400 response try { input = (await request.json()) as { endpoint?: unknown; cookie?: unknown }; } catch { From efb7cba364a9ba8becad4c8bdbe4f7f084a78ea7 Mon Sep 17 00:00:00 2001 From: darjss Date: Sat, 8 Aug 2026 16:49:31 +0800 Subject: [PATCH 03/10] chore(host-cloudflare): configure darjs deployment --- apps/host-cloudflare/wrangler.jsonc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index 9259d50ce..ffe3ac5f7 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -4,6 +4,7 @@ "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], "main": "src/worker.ts", + "routes": [{ "pattern": "executor-erxes-os.darjs.dev", "custom_domain": true }], // Access configuration is set per installation after the first deploy. // Preserve those live bindings when this source config does not declare them. "keep_vars": true, @@ -29,7 +30,7 @@ { "binding": "DB", "database_name": "executor", - "database_id": "ae748ca1-032c-4427-a1a0-fe39db77d1a9", + "database_id": "8e0f791e-c9fa-4ce0-a3dd-fa36a03ca102", }, ], // Plugin blob seam backend: multi-MB values (resolved OpenAPI specs, From 656e908ab14b8ad51f96bdb650855173598bcd8f Mon Sep 17 00:00:00 2001 From: darjss Date: Sat, 8 Aug 2026 17:17:39 +0800 Subject: [PATCH 04/10] chore(host-cloudflare): configure Access --- apps/host-cloudflare/wrangler.jsonc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index ffe3ac5f7..4bc729704 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -72,8 +72,13 @@ // secret-encryption key) is a SECRET, set it with // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. "vars": { + // Keep normal Executor routes fail-closed until a real Access app replaces these values. + // Signed /os/* calls use CLOUDFLARE_OS_AUTH_SECRET instead. + "ACCESS_TEAM_DOMAIN": "darjs.cloudflareaccess.com", + "ACCESS_AUD": "4b0e36b2e0b981ad929a4657fabe04e855deb8bf77636f259f26f73dc822ae88", "ACCESS_NAME_CLAIM": "name", "ACCESS_GROUPS_CLAIM": "groups", + "ADMIN_EMAILS": "darjsavid@gmail.com", // Never preserve a production dev-auth override through keep_vars. "ENABLE_DEV_AUTH": "false", "SELF_HOSTED_ORG_ID": "default", From 851daa7d4fa881700fbcdc954a204230cfb2ab2f Mon Sep 17 00:00:00 2001 From: darjss Date: Mon, 10 Aug 2026 16:01:40 +0800 Subject: [PATCH 05/10] fix(graphql): expose nested result shapes --- .changeset/bright-graphql-results.md | 5 ++ .../plugins/graphql/src/sdk/plugin.test.ts | 15 ++++ packages/plugins/graphql/src/sdk/plugin.ts | 75 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 .changeset/bright-graphql-results.md diff --git a/.changeset/bright-graphql-results.md b/.changeset/bright-graphql-results.md new file mode 100644 index 000000000..dfdbf5dd6 --- /dev/null +++ b/.changeset/bright-graphql-results.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-graphql": patch +--- + +Expose bounded GraphQL return shapes so agents can build nested `select` clauses from tool descriptions. diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 7a9ef3161..add8d5ade 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -1305,6 +1305,21 @@ describe("graphqlPlugin generates valid operations against rich schemas (#1146)" }), ); + it.effect("describes nested return fields for caller-supplied selections", () => + Effect.gen(function* () { + const { executor } = yield* setup("gitlab_output_shape"); + + const schema = yield* executor.tools.schema( + toolAddr("gitlab_output_shape", "main", "query.currentUser"), + ); + + expect(schema?.outputTypeScript).toContain("currentUser"); + expect(schema?.outputTypeScript).toContain("mergeRequests"); + expect(schema?.outputTypeScript).toContain("nodes"); + expect(schema?.outputTypeScript).toContain("title"); + }), + ); + it.effect("a caller-supplied `select` fetches nested/list data and stays valid", () => Effect.gen(function* () { const { server, executor } = yield* setup("gitlab_select"); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 6e162d29d..f6b92f9dd 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -395,6 +395,78 @@ const buildDefaultSelectionSet = ( return leaves.length > 0 ? `{ ${leaves.join(" ")} }` : "{ __typename }"; }; +// A GraphQL call can return any caller-selected subset, so every property stays optional and +// every object permits extra fields. The bounded expansion gives tools.describe enough real type +// information to author `select` without copying an unbounded schema into every tool row. +const MAX_OUTPUT_SHAPE_DEPTH = 3; + +const outputScalarToJsonSchema = (name: string): Record => { + if (name === "Int") return { type: "integer" }; + if (name === "Float") return { type: "number" }; + if (name === "Boolean") return { type: "boolean" }; + if (name === "JSON") return {}; + return { + type: "string", + ...(name === "String" || name === "ID" + ? {} + : { + description: `Custom scalar: ${name}`, + }), + }; +}; + +const outputTypeRefToJsonSchema = ( + ref: IntrospectionTypeRef, + types: ReadonlyMap, + depth = 0, + ancestors: ReadonlySet = new Set(), +): Record => { + if (ref.kind === "NON_NULL" && ref.ofType) { + return outputTypeRefToJsonSchema(ref.ofType, types, depth, ancestors); + } + if (ref.kind === "LIST") { + return { + type: "array", + items: ref.ofType ? outputTypeRefToJsonSchema(ref.ofType, types, depth, ancestors) : {}, + }; + } + + const typeName = unwrapTypeName(ref); + const type = types.get(typeName); + if (type?.kind === "ENUM") { + return { type: "string", enum: type.enumValues?.map((value) => value.name) ?? [] }; + } + if (type?.kind === "SCALAR" || ref.kind === "SCALAR") { + return outputScalarToJsonSchema(typeName); + } + if (!type?.fields || depth >= MAX_OUTPUT_SHAPE_DEPTH || ancestors.has(typeName)) { + return { type: "object", description: `GraphQL type ${typeName}`, additionalProperties: true }; + } + + const nextAncestors = new Set(ancestors); + nextAncestors.add(typeName); + const properties = Object.fromEntries( + type.fields + .filter((field) => !field.name.startsWith("__")) + .map((field) => [ + field.name, + outputTypeRefToJsonSchema(field.type, types, depth + 1, nextAncestors), + ]), + ); + return { type: "object", properties, additionalProperties: true }; +}; + +const buildOutputSchema = ( + field: IntrospectionField, + types: ReadonlyMap, +): Record => ({ + type: "object", + properties: { + [field.name]: outputTypeRefToJsonSchema(field.type, types), + }, + additionalProperties: true, +}); + // Name every generated operation: some servers reject anonymous operations, and // APM tooling keys traces off the operation name. Field names are already valid // GraphQL name tokens, so the upper-cased field name is a safe operation name. @@ -443,6 +515,7 @@ interface PreparedOperation { readonly toolName: string; readonly description: string; readonly inputSchema: unknown; + readonly outputSchema?: unknown; readonly binding: OperationBinding; } @@ -529,6 +602,7 @@ const prepareOperations = ( Option.getOrUndefined(extracted.inputSchema), extracted.returnTypeName, ), + ...(entry ? { outputSchema: buildOutputSchema(entry.field, typeMap) } : {}), binding, }; }); @@ -571,6 +645,7 @@ const buildToolDefs = (prepared: readonly PreparedOperation[]): readonly ToolDef name: ToolName.make(p.toolName), description: p.description, inputSchema: p.inputSchema, + ...(p.outputSchema !== undefined ? { outputSchema: p.outputSchema } : {}), annotations: annotationsFor(p.binding), })); From a35045eb3b8bbb60e2c5b0f901639fc8a671cae6 Mon Sep 17 00:00:00 2001 From: darjss Date: Mon, 10 Aug 2026 16:45:43 +0800 Subject: [PATCH 06/10] fix(execution): teach agents GraphQL selections --- .changeset/bright-graphql-results.md | 3 +- packages/core/execution/src/index.ts | 1 + packages/core/execution/src/skills.test.ts | 19 +++++++- packages/core/execution/src/skills.ts | 52 +++++++++++++++++++++- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/.changeset/bright-graphql-results.md b/.changeset/bright-graphql-results.md index dfdbf5dd6..23d46aec0 100644 --- a/.changeset/bright-graphql-results.md +++ b/.changeset/bright-graphql-results.md @@ -1,5 +1,6 @@ --- "@executor-js/plugin-graphql": patch +"@executor-js/execution": patch --- -Expose bounded GraphQL return shapes so agents can build nested `select` clauses from tool descriptions. +Expose bounded GraphQL return shapes and teach agents to request nested rows with explicit `select` clauses. diff --git a/packages/core/execution/src/index.ts b/packages/core/execution/src/index.ts index 3e68509cd..ef23ea755 100644 --- a/packages/core/execution/src/index.ts +++ b/packages/core/execution/src/index.ts @@ -14,6 +14,7 @@ export { export { buildExecuteDescription, INTEGRATION_INVENTORY_HEADER } from "./description"; export { EXECUTE_SKILL, + GRAPHQL_SKILL, CREATE_ARTIFACT_SKILL, SKILLS, findSkill, diff --git a/packages/core/execution/src/skills.test.ts b/packages/core/execution/src/skills.test.ts index 54a1c049b..6a49a19dc 100644 --- a/packages/core/execution/src/skills.test.ts +++ b/packages/core/execution/src/skills.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { EXECUTE_SKILL, SKILLS, findSkill, renderSkillsIndex, skillCatalogFor } from "./skills"; +import { + EXECUTE_SKILL, + GRAPHQL_SKILL, + SKILLS, + findSkill, + renderSkillsIndex, + skillCatalogFor, +} from "./skills"; describe("skills registry", () => { it("includes the execute skill with the full how-to body", () => { @@ -15,8 +22,18 @@ describe("skills registry", () => { ); }); + it("teaches execute callers to fetch the GraphQL selection guide", () => { + expect(SKILLS).toContain(GRAPHQL_SKILL); + expect(EXECUTE_SKILL.body).toContain('skills({ name: "graphql" })'); + expect(GRAPHQL_SKILL.body).toContain( + 'select: "list { _id firstName lastName primaryEmail } totalCount"', + ); + expect(GRAPHQL_SKILL.body).toContain("Use the same `select` input in artifact"); + }); + it("finds a skill by exact name and misses unknown names", () => { expect(findSkill("execute")).toBe(EXECUTE_SKILL); + expect(findSkill("graphql")).toBe(GRAPHQL_SKILL); expect(findSkill("Execute")).toBeUndefined(); expect(findSkill("nope")).toBeUndefined(); }); diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 1036ae697..c528b3457 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -36,8 +36,9 @@ const EXECUTE_SKILL_BODY = [ '2. `const path = matches[0]?.path; if (!path) return "No matching tools found.";`', "3. `const details = await tools.describe.tool({ path });`", "4. Use `details.inputTypeScript` / `details.outputTypeScript` and `details.typeScriptDefinitions` for compact shapes.", - "5. Use `tools.executor.coreTools.connections.list({})` when you need live saved-connection inventory.", - "6. Call the tool: `const result = await tools.(input);`", + '5. If `details.inputTypeScript` includes `select?: string`, fetch `skills({ name: "graphql" })` before calling the tool.', + "6. Use `tools.executor.coreTools.connections.list({})` when you need live saved-connection inventory.", + "7. Call the tool: `const result = await tools.(input);`", "", "## Rules", "", @@ -71,6 +72,52 @@ export const EXECUTE_SKILL: Skill = { body: EXECUTE_SKILL_BODY, }; +const GRAPHQL_SKILL_BODY = [ + "# graphql", + "", + "Call generated GraphQL tools with an explicit return-field selection.", + "", + "## Why `select` matters", + "", + "Generated GraphQL tools select only scalar fields on the return type by default. They deliberately omit nested objects and lists, since expanding them automatically can create huge or invalid queries. A list query may therefore return `totalCount` but omit `list` even when rows exist.", + "", + "`outputTypeScript` describes fields that are available to request. It does not mean every field is returned when `select` is omitted.", + "", + "## Workflow", + "", + "1. Call `tools.describe.tool({ path })`.", + "2. Confirm `inputTypeScript` includes `select?: string`.", + "3. Read the root return shape from `outputTypeScript`.", + "4. Pass the fields you need as GraphQL selection text.", + "5. Read the response under the tool's root field as shown in `outputTypeScript`.", + "", + "```ts", + "const result = await tools[path]({", + " limit: 50,", + ' select: "list { _id firstName lastName primaryEmail } totalCount",', + "});", + "if (!result.ok) return result.error;", + "return result.data.customers;", + "```", + "", + "## Rules", + "", + "- Write fields for the GraphQL return type, not the operation root. Use `list { ... } totalCount`, not `customers { list { ... } }`.", + "- Do not wrap the whole string in braces. Executor adds them.", + "- `select` replaces the default selection. Include scalar fields such as `totalCount` when you still need them.", + "- Give every object or list a sub-selection: `list { _id name }`.", + "- Keep selections small. Ask only for fields the task uses.", + "- Use the same `select` input in artifact `queryOptions(...)` calls so the saved UI receives the rows it renders.", + "- If GraphQL reports `Cannot query field`, compare the selection with `outputTypeScript`; do not guess another field name.", +].join("\n"); + +export const GRAPHQL_SKILL: Skill = { + name: "graphql", + summary: + "How to use `select` with generated GraphQL tools so nested objects and list rows are returned, with the same input in artifacts.", + body: GRAPHQL_SKILL_BODY, +}; + // The `create-artifact` how-to. Same reasoning as `execute`: the discovery-vs-render // protocol, the TanStack rules and the component inventory are a page of prose // that only matters once a model decides to build a UI, so the tool description @@ -615,6 +662,7 @@ export const ARTIFACT_STYLE_SKILL: Skill = { /** The full skill catalog. Hand-curated; keep it small. */ export const SKILLS: readonly Skill[] = [ EXECUTE_SKILL, + GRAPHQL_SKILL, CREATE_ARTIFACT_SKILL, ARTIFACT_STYLE_SKILL, ]; From dbccd2f17cba7f19cb178ce9026d007b9a552188 Mon Sep 17 00:00:00 2001 From: darjss Date: Tue, 11 Aug 2026 14:05:44 +0800 Subject: [PATCH 07/10] chore(host-cloudflare): align Erxes tenant --- apps/host-cloudflare/wrangler.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index 4bc729704..426a8b683 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -81,8 +81,8 @@ "ADMIN_EMAILS": "darjsavid@gmail.com", // Never preserve a production dev-auth override through keep_vars. "ENABLE_DEV_AUTH": "false", - "SELF_HOSTED_ORG_ID": "default", - "SELF_HOSTED_ORG_NAME": "Default", + "SELF_HOSTED_ORG_ID": "officenext.erxes.io", + "SELF_HOSTED_ORG_NAME": "OfficeNext", // VITE_PUBLIC_SITE_URL is intentionally unset: with no static URL the worker // derives the web base URL from each request's origin (RequestWebOrigin), so // secret/OAuth handoff links match whatever host the user actually reached. From 6bddd556f2fd91ba5b8ce23cce1ed6e24bdde813 Mon Sep 17 00:00:00 2001 From: darjss Date: Wed, 26 Aug 2026 19:01:53 +0800 Subject: [PATCH 08/10] fix(host-mcp): declare execute and resume as read-only for OS action routing --- packages/hosts/mcp/src/tool-server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index c0643c4a6..0e437cf38 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1491,6 +1491,7 @@ export const createExecutorMcpServer = ( { description, inputSchema: { code: z.string().trim().min(1) }, + annotations: { readOnlyHint: true }, }, ({ code }, extra) => runToolEffect(executeCode(code, extra)), ), @@ -1549,6 +1550,7 @@ export const createExecutorMcpServer = ( .describe("Optional JSON-encoded response content for form elicitations") .default("{}"), }, + annotations: { readOnlyHint: true }, }, ({ executionId, action, content: rawContent }, extra) => runToolEffect( From 8963d5cc774fdbb5f8b29551bffe055a8f3609c0 Mon Sep 17 00:00:00 2001 From: darjss Date: Thu, 27 Aug 2026 14:06:57 +0800 Subject: [PATCH 09/10] fix(graphql): return list rows by default and resolve unique short names List-of-object fields were omitted from the default selection, so wrappers with totalCount succeeded with no rows. Short names from tools.search 404'd because the sandbox requires a five-segment address. --- packages/core/execution/src/skills.ts | 2 +- .../core/execution/src/tool-invoker.test.ts | 94 +++++++++++++++++++ packages/core/execution/src/tool-invoker.ts | 79 +++++++++++++++- packages/plugins/graphql/src/sdk/invoke.ts | 11 ++- .../plugins/graphql/src/sdk/plugin.test.ts | 88 +++++++++++++++++ packages/plugins/graphql/src/sdk/plugin.ts | 82 +++++++++++----- packages/plugins/graphql/src/testing/index.ts | 28 ++++++ 7 files changed, 355 insertions(+), 29 deletions(-) diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index c528b3457..f2ddde546 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -79,7 +79,7 @@ const GRAPHQL_SKILL_BODY = [ "", "## Why `select` matters", "", - "Generated GraphQL tools select only scalar fields on the return type by default. They deliberately omit nested objects and lists, since expanding them automatically can create huge or invalid queries. A list query may therefore return `totalCount` but omit `list` even when rows exist.", + "Generated GraphQL tools select scalar fields on the return type by default, plus one level of item scalars on list-of-object fields. Nested objects and connection fields still need an explicit `select`.", "", "`outputTypeScript` describes fields that are available to request. It does not mean every field is returned when `select` is omitted.", "", diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index dd25e6168..1409f39c5 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -249,6 +249,34 @@ const crmPlugin = makeTestPlugin({ ], }); +const crmAltPlugin = makeTestPlugin({ + pluginId: "crm-alt-test", + integration: "crm_alt", + tools: [ + { + name: "createContact", + description: "Create a contact in the other CRM", + inputJsonSchema: ContactInputJson, + validator: ContactValidator, + handler: () => Effect.succeed({ id: "contact_alt" }), + }, + ], +}); + +const dottedNamePlugin = makeTestPlugin({ + pluginId: "dotted-name-test", + integration: "catalog", + tools: [ + { + name: "query.records", + description: "List records", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: () => Effect.succeed({ totalCount: 1 }), + }, + ], +}); + const errorPlugin = makeTestPlugin({ pluginId: "error-test", integration: "records", @@ -1110,6 +1138,72 @@ describe("tool discovery", () => { }), ); + it.effect("resolves a unique short tool name to the qualified path", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + const invoker = makeExecutorToolInvoker(executor, { + invokeOptions: { onElicitation: acceptAll }, + }); + + const result = yield* invoker.invoke({ + path: "createContact", + args: { email: "a@b.com" }, + }); + expect(result).toEqual({ ok: true, data: { id: "contact_1" } }); + + const described = yield* describeTool(executor, "createContact"); + expect(described.path).toBe("crm.org.main.createContact"); + expect(described.error).toBeUndefined(); + }), + ); + + it.effect("resolves a unique dotted short name", () => + Effect.gen(function* () { + const executor = yield* makeExecutorWith([dottedNamePlugin] as const); + yield* provision(executor as never, [ + { pluginId: "dotted-name-test", integration: "catalog" }, + ]); + const invoker = makeExecutorToolInvoker(executor, { + invokeOptions: { onElicitation: acceptAll }, + }); + + const result = yield* invoker.invoke({ path: "query.records", args: {} }); + expect(result).toEqual({ ok: true, data: { totalCount: 1 } }); + }), + ); + + it.effect("does not guess when a short name matches more than one tool", () => + Effect.gen(function* () { + const executor = yield* makeExecutorWith([crmPlugin, crmAltPlugin] as const); + yield* provision(executor as never, [ + { pluginId: "crm-test", integration: "crm" }, + { pluginId: "crm-alt-test", integration: "crm_alt" }, + ]); + const invoker = makeExecutorToolInvoker(executor, { + invokeOptions: { onElicitation: acceptAll }, + }); + + const result = yield* invoker.invoke({ + path: "createContact", + args: { email: "a@b.com" }, + }); + expect(result).toMatchObject({ + ok: false, + error: { + code: "tool_not_found", + details: { + path: "createContact", + }, + }, + }); + const suggestions = (result as { error: { details: { suggestions: string[] } } }).error + .details.suggestions; + expect(suggestions).toEqual( + expect.arrayContaining(["crm.org.main.createContact", "crm_alt.org.main.createContact"]), + ); + }), + ); + it.effect("returns user-actionable typed errors as ToolResult.fail", () => Effect.gen(function* () { const executor = yield* makeExecutorWith([userActionableErrorPlugin] as const); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 4da925176..24b831274 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -299,17 +299,74 @@ const extractNamespace = (path: string): string => { * because it would require an `integrations.list()` lookup on every invocation. * Callers that already know the integration kind can annotate at their own span. */ +type ResolvedSandboxPath = + | { readonly kind: "ok"; readonly path: string } + | { readonly kind: "ambiguous"; readonly suggestions: readonly string[] }; + +const matchesRequestedTool = ( + tool: { readonly path: string; readonly name: string }, + requested: string, +): boolean => + tool.name === requested || tool.path === requested || tool.path.endsWith(`.${requested}`); + +/** Fully-qualified sandbox paths parse as `tools....`. + * Short names (`query.records`, `createContact`) do not, and 404 unless rewritten. */ +const resolveSandboxToolPath = Effect.fn("executor.tools.resolvePath")(function* ( + executor: Executor, + path: string, +) { + if (parseToolAddress(String(pathToAddress(path)))) { + return { kind: "ok", path } satisfies ResolvedSandboxPath; + } + + const all = yield* executor.tools.list({ includeAnnotations: false }).pipe( + Effect.mapError( + (cause) => + new ExecutionToolError({ + message: "Failed to list tools for path resolution", + cause, + }), + ), + ); + const matches = all + .map((tool) => ({ path: addressToPath(String(tool.address)), name: String(tool.name) })) + .filter((tool) => matchesRequestedTool(tool, path)); + + if (matches.length === 1) { + return { kind: "ok", path: matches[0]!.path } satisfies ResolvedSandboxPath; + } + if (matches.length > 1) { + return { + kind: "ambiguous", + suggestions: matches.map((tool) => tool.path), + } satisfies ResolvedSandboxPath; + } + return { kind: "ok", path } satisfies ResolvedSandboxPath; +}); + export const makeExecutorToolInvoker = ( executor: Executor, options: { readonly invokeOptions: InvokeOptions }, ): SandboxToolInvoker => ({ invoke: Effect.fn("mcp.tool.dispatch")(function* ({ path, args }) { + const resolved = yield* resolveSandboxToolPath(executor, path); + if (resolved.kind === "ambiguous") { + const result = ToolResult.fail({ + code: "tool_not_found", + message: `Tool not found: ${path}`, + details: { path, suggestions: resolved.suggestions }, + }); + yield* annotateToolResultOutcome(result); + return result; + } + const dispatchPath = resolved.path; + yield* Effect.annotateCurrentSpan({ - "mcp.tool.name": path, - "mcp.tool.integration": extractNamespace(path), + "mcp.tool.name": dispatchPath, + "mcp.tool.integration": extractNamespace(dispatchPath), }); - const address = pathToAddress(path); + const address = pathToAddress(dispatchPath); const result = yield* executor.execute(address, args, options.invokeOptions).pipe( Effect.catchTag("CredentialResolutionError", (err) => Effect.succeed( @@ -345,7 +402,7 @@ export const makeExecutorToolInvoker = ( return Effect.logError("tool dispatch failed", cause).pipe( Effect.annotateLogs({ "executor.correlation_id": correlationId, - "mcp.tool.name": path, + "mcp.tool.name": dispatchPath, }), Effect.flatMap(() => Effect.fail( @@ -825,6 +882,20 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( const builtin = BUILTIN_TOOL_DESCRIPTIONS.get(path); if (builtin) return builtin; + const resolved = yield* resolveSandboxToolPath(executor, path); + if (resolved.kind === "ambiguous") { + return { + path, + name: path, + error: { + code: "tool_not_found", + message: `Tool not found: ${path}`, + suggestions: resolved.suggestions, + }, + } satisfies DescribedTool; + } + path = resolved.path; + const address = pathToAddress(path); // Single tools.schema() call — it already fetches the tool row diff --git a/packages/plugins/graphql/src/sdk/invoke.ts b/packages/plugins/graphql/src/sdk/invoke.ts index b3ae55e20..0475484fb 100644 --- a/packages/plugins/graphql/src/sdk/invoke.ts +++ b/packages/plugins/graphql/src/sdk/invoke.ts @@ -35,18 +35,25 @@ const formatTimeout = (timeoutMs: number): string => const invocationTimeoutMessage = (timeoutMs: number): string => `GraphQL upstream did not complete within ${formatTimeout(timeoutMs)}. The request was aborted. Retry the operation or verify that the endpoint is responsive.`; +const unwrapOuterSelectionBraces = (select: string): string => { + const trimmed = select.trim(); + return trimmed.startsWith("{") && trimmed.endsWith("}") ? trimmed.slice(1, -1).trim() : trimmed; +}; + /** The operation string to send for a call. A caller-supplied `select` overrides * the default scalar-leaf selection: it is spliced into the field's selection * set (`field {