| name | app-mcp-implement |
| description | Build a brand-new Prisme.ai App+MCP connector for a third-party SaaS using the tenant-context model (no HMAC), an entity-grouped registry driven by a generated OpenAPI spec, multi-mode auth (API key / OAuth2 client-credentials / OAuth2 per-user PKCE / JWT service-account) resolved by buildAppAuth, a central platform OAuth client (token-service model — zero-config `oauthCentral` tenant mode + maintainer view in the config SPA), per-user OAuth tokens resolvable from cron, a model-B config SPA, and optionally a **MCP Knowledge Resources** surface so the connector can feed a knowledge base (Phase 9). Reproduces the validated `salesforce-next` build (+ `google-workspaces` central OAuth) for any service. Use when the user says "build an app+mcp for X", "créer une app+mcp pour X", "implémente un connecteur X". Everything needed is here + in `reference/`. |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch, AskUserQuestion, Agent, mcp__prisme-ai-builder__get_prisme_documentation, mcp__prisme-ai-builder__validate_automation, mcp__prisme-ai-builder__push_workspace, mcp__prisme-ai-builder__upload_file, mcp__prisme-ai-builder__create_workspace, mcp__prisme-ai-builder__search_workspaces, mcp__prisme-ai-builder__search_events, mcp__prisme-ai-builder__get_app_instance_config, mcp__prisme-ai-builder__update_app_instance_config |
App + MCP connector builder (tenant-context model)
You are building a Prisme.ai workspace published as an app ("app+mcp"). It is never used directly — it is consumed as an appInstance inside a tenant workspace. Its job: wrap a third-party API as App-mode instructions (<AppSlug>.<op>:) AND expose an MCP server that AI agents call. Both run in the tenant app-instance context (the MCP webhook is /workspaces/<tenantId>/webhooks/<appInstanceSlug>.mcp), so the connector reads the tenant's own config.*/secrets locally — there is no central HMAC key, no cross-workspace getConfig, no central/tenant split.
This skill reproduces, for a NEW service, the exact build we validated on salesforce-next. That connector is bundled at reference/ inside this skill — it is the canonical, working implementation. Read it constantly. Your task = mirror it for the target service, adapting only the service-specific parts (the API surface and the available auth methods).
The older HMAC-based connector implementation is deprecated. Do not copy its generateKey/getConfig/mcp-auto-install/per-tool 3-layer patterns — they are gone in this model.
The model in one screen (read before doing anything)
- Tenant-context MCP endpoint (
automations/mcp.yml): JSON-RPC 2.0. tools/call → extract agent_id (injected by agent-factory's capability scope) → validateAgent (allowlist) → buildAppAuth → routeToolCall. Everything else → MCP Core (imports/MCP Core.yml, handleMcpMethod) which serves initialize/tools/list. MCP notifications (JSON-RPC with no id, e.g. notifications/initialized) MUST return HTTP 202 or strict MCP clients (agent-factory) fail the handshake and never register the tools.
- Entity-grouped tools + registry dispatch: tools are entities (e.g.
records, query, mail, files) each taking an action enum, NOT one tool per endpoint. The mapping entity+action → operationName → {method, path, params} lives in a generated registry inside imports/Custom Code.yml (resolveToolAction / getOperation / buildSalesforceRequest). Dispatch chain: routeToolCall → toolRestOp / methodRestOp → executeApiCall → formatToolOutput (+ handleApiError). The per-endpoint App-mode automations (<op>.yml, e.g. runQuery.yml) are thin public wrappers, reached dynamically via the registry — they have no static slug references and that is by design.
- Multi-mode auth in
buildAppAuth is resolved from field-level tenant config values such as config.authMode, config.oauthClientId, and config.oauthClientSecret: branches per mode return {accessToken, baseUrl} or {error}. Runtime code may assemble a temporary auth object for convenience, but it must not persist that object as a secret.
- Secret storage: one secret key = one plain value. Each secret entry must contain a single password, token, client ID, scope string, or similar value. Do not store JSON objects, arrays, YAML mappings, or serialized credential bundles in either secret store. Give every credential field its own secret key, such as , , and . Non-secret structured runtime state is unaffected. because a scalar secret has no property to read, an unresolved binding is a TRUTHY literal string — never test a secret-backed binding directly in a DSUL condition; resolve it through the Custom Code helper first (see Gotcha 26).
Full rationale + every gotcha is in the project memory app-mcp-refacto-design and the § Gotchas section below.
Non-negotiable: every deploy-specific value is secret-backed
Every value that can vary by provider, tenant, customer, installation, account, project, resource, or environment is configuration and MUST live in the secret store. This includes tenant/client/subscription IDs, client secrets, resource groups/IDs, provider endpoints and base URLs, account/project/resource names or IDs, model deployments, configurable API versions, connection IDs, OAuth scopes/audiences, API keys/tokens/certificates, and any equivalent provider-specific value.
For each such value, the connector MUST:
- declare it (or its containing typed object and property) in
secrets.schema;
- provision/write it through
/security/secrets or the supported secure-secret module;
- expose it to the instance only through the existing 2-hop binding: instance
config.X = {{config.<camel>X}} → tenant config.value.<camel>X = {{secret.<camel>X}} → secret store;
- make it editable in the config SPA or explicit installation flow.
It is forbidden to copy these values in clear text into config.value, index.yml, automations, imports, the SPA, tests, fixtures, docs, logs, emitted events, or error messages. Never echo a secret-bearing object or provider response that can contain credentials. Fetches containing credentials use emitErrors: false; errors report only a bounded status/code and remediation.
Only strictly structural or behavioral constants that identify no external resource may stay versioned: mcpTools and schemas, bundle pointers/metadata, internal limits, UX defaults/placeholders, and genuinely universal protocol constants. A provider URL, scope, audience, API version, resource name/ID, or environment selector is never a protocol constant.
Anonymous external webhooks do not weaken this rule. If a runtime cannot resolve secrets in anonymous context, route the call through an authenticated server-side/token-service relay that reads the secret, or declare that execution mode unsupported and fail closed. Never mirror secret-backed values into plaintext instance config or Collections.
Token caches MUST be context-bound. Use one deterministic cache key derived from at least the effective tenant/workspace ID, OAuth client ID, and normalized scopes. Store that key with cached tokens and include it in secret/cache names where applicable. If the derived key changes, delete or ignore the previous token before reuse.
Generic vs service-specific (what to copy vs regenerate)
Copy ~verbatim from reference/, only parameterize names (see placeholders):
automations/mcp.yml, validateAgent.yml, routeToolCall.yml, toolRestOp.yml, methodRestOp.yml, executeApiCall.yml, formatToolOutput.yml, handleApiError.yml, testAuth.yml, onInstall.yml
automations/oauthConnect.yml, oauthCallback.yml, oauthStatus.yml, oauthDisconnect.yml (OAuth2 authcode+PKCE is standard; swap only the provider authorize/token/revoke URLs, scopes, and host rule)
imports/MCP Core.yml (verbatim), security.yml (verbatim)
imports/Custom Code.yml generic helpers: isAgentAllowed, makeConfigRef, makeSecretRef, makeTokenSecretName, normalizeLoginHost (rename → normalizeAuthHost if the host rule differs), generatePkce, generateState, buildAuthorizeUrl, buildJwtAssertion (drop if no JWT mode), buildQueryString
pages/<slug>/ SPA: everything except src/App.tsx's MODES/FIELDS/PREAMBLE/AuthConfig (those follow the auth modes) and the connector strings. scripts/externals.mjs, components/, lib/, vite/ts config = verbatim.
Regenerate per service (this is the actual work):
swagger.yml (the API surface) → the registry tables ENTITY_OPS + OPERATIONS in imports/Custom Code.yml (resolveToolAction/getOperation/buildSalesforceRequest) → the per-op App-mode automations → index.yml mcpTools + entity tool inputSchemas.
buildAppAuth.yml branches + the SPA MODES/FIELDS/PREAMBLE → the service's available auth methods.
baseUrl shape: Salesforce = one {instance_url}/services/data/{ver}. Multi-API services (e.g. Google: drive/docs/sheets/gmail/calendar) have DIFFERENT host/base per API → carry an absolute base (or host) per op in the OPERATIONS entry and have executeApiCall use it, instead of one global baseUrl. Decide this early.
Placeholders (substitute everywhere when copying)
| Placeholder | Meaning | salesforce-next value | google example |
|---|
<slug> | workspace (workspaces/<slug>/ DSUL) + SPA route + repo-root SPA folder pages/<slug>/ (sibling of workspaces/<slug>/) | salesforce-next | google-workspace |
<AppSlug> | workspace name AND published app id (PascalCase) + appInstance prefix in webhooks — the importer derives the app id from the workspace name verbatim, so name it <AppSlug>; also publish_app(slug:<AppSlug>) + verify (see Phase 8) | SalesforceNext | GoogleWorkspace |
<camel> | var/secret/event prefix | salesforceNext | googleWorkspace |
<pfx> | per-user token secret prefix | sfn (sfnRefresh_<id>) | gws |
<Service> | human label | Salesforce Next | Google Workspace |
Service auth specifics to fill: authorize/token/revoke URLs, OAuth scopes, host-normalization rule (if any), JWT audience, and the baseUrl/per-op host shape.
Workflow
Repo layout (since 2026-06-25). The connector spans TWO sibling top-level folders at the repo root, linked 1:1 by name: workspaces/<slug>/ holds the DSUL only (index.yml, automations/, imports/, security.yml) and pages/<slug>/ holds the React config SPA (src/, scripts/, package.json, dist/, node_modules/). The workspace folder no longer nests the SPA (the old workspaces/<slug>/pages/<appName>/ nested layout is deprecated). push_workspace targets workspaces/<slug>/ (DSUL); the SPA is deployed separately by deploy.mjs from pages/<slug>/. (The bundled reference/ keeps both halves under one tree for convenience — reference/ = the DSUL, reference/pages/salesforce-next/ = the SPA — but a real connector splits them across the two sibling folders.)
Offer a worktree first (per repo convention): branch workspace/<slug>-scaffold from sandbox. Do all edits there.
Phase 1 — Service identity + AUTH MODES (the key decision)
Ask / confirm: service name, <slug>/<AppSlug>/<camel>/<pfx>, the API docs URL(s). Then determine which auth methods the service supports and which to expose. Use AskUserQuestion if ambiguous. Common modes (mirror buildAppAuth branches + SPA MODES):
oauth — OAuth2 authorization_code + PKCE, per-user (interactive + cron-via-targetUserId). Almost always offer this.
clientCredentials — OAuth2 client_credentials (service identity).
jwt — JWT Bearer / service account (e.g. Google domain-wide delegation, Salesforce Connected App). Uses buildJwtAssertion.
accessToken — caller-supplied token + base/instance (quick, no exchange; great for testing).
apiKey — static API key in a header/query (many simple APIs). Add a branch returning {apiKeyHeader/value, baseUrl} and have executeApiCall send it.
Record, for each chosen mode, the exact provider endpoints/fields. This drives Phases 6–7.
Create a configuration inventory at this point. Every provider/tenant/environment field in that inventory is a secret-schema field and a SPA/install field; do not assign runtime defaults in code.
Phase 2 — Generate swagger.yml (kept in git, ignored by Prisme)
Fetch the official API docs (WebFetch/WebSearch the OpenAPI spec). Produce a single OpenAPI 3.x swagger.yml at the workspace root. Prisme's importer ignores unknown root files, but commit it — it is the source of truth for the registry and must be retrievable from git. For multi-API services, merge the APIs into one spec, tagging each operation with its API/base host. Keep operationIds stable; they become the registry operationNames.
Phase 3 — Logo / photo (required to publish the app)
Find the service's official logo (WebSearch). Always save it locally in <workspace>/assets/ and commit it to git FIRST — so the source is recoverable if the uploaded file is ever lost (e.g. the workspace it was uploaded to gets deleted, taking its /files/ with it). Then upload_file (public) and set index.yml photo: to the returned URL. An app cannot be published without a photo — do this before the first push. Apply the same assets/ rule to ANY binary the connector depends on (icons, sample payloads, fixtures) — keep the source in assets/ + git, never only in the platform's /files/.
Phase 4 — Scaffold the generic infra
Create the workspace (create_workspace or copy reference/ and adjust index.yml id/slug/name/photo). Copy + parameterize the generic files listed above (mcp, validateAgent, the dispatcher chain, executeApiCall, formatToolOutput, handleApiError, onInstall, testAuth, MCP Core, security, Custom Code generic helpers, index.yml skeleton). Substitute placeholders. Do NOT carry over the reference's id or any bundle URL.
- Before writing runtime code, map every provider/tenant/environment value to a
secrets.schema property, a secret-store write, binding B, binding A, and a SPA/install control. The scaffold is invalid if any mapping is missing.
onInstall.yml: provisions ONE tenant secret per auth field (<camel>AuthMode, <camel>LoginHost, <camel>OAuthClientId, <camel>OAuthClientSecret, <camel>OAuthScopes, <camel>ApiVersion, …) plus <camel>AuthorizedAgents — each holding a single plain value; NEVER an aggregate <camel>Auth object; writes one binding B per field (workspace config.value via minted workspace JWT + makeSecretRef) then one binding A per field (terminal set: config merge via makeConfigRef); sets configAppUrl + (OAuth connectors only) oauthCallbackUrl. Exposed endpoints use slug:<slug> by default (the slug survives a re-import; the workspace id changes on every re-import while you debug an install, so a slug-form URL stays stable across attempts). Two documented exceptions are FORCED to the raw id: (1) the OAuth callback / redirect URI only when the provider is Microsoft Entra — Entra rejects the : of slug:<slug> in a redirect URI (Google/Salesforce tolerate it → keep slug); (2) the internal /config + /security/secrets calls — those endpoints don't resolve slug:<slug> (404), they require the raw id. Resolve both forms once via GET /workspaces/{{global.workspaceId}} → wsInfo.id (raw) + wsInfo.slug; build slug:{{wsInfo.slug}} for the slug-form URLs and wsInfo.id for the two exceptions (see the powerbi connector for the Microsoft case). Do NOT store mcpEndpoint on the instance config — the SPA derives it client-side from the workspaceId it receives via configAppUrl. Idempotent.
index.yml: config.schema (configAppUrl readOnly only — NO mcpEndpoint or provider/runtime field), config.block = the SPA bundle ref, a RELATIVE path (never an absolute URL) (sibling of schema/value — makes the one-product builder render the SPA inline as the config UI; Gotcha 31; keep it equal to and re-point both on every redeploy), (only 2-hop binding aliases, structural , and ), (all auth/provider/tenant/environment configuration + ) — . Configurable API versions and provider URLs belong in secrets, never as literals in . — it auto-publishes on the first push and locks a name-derived store slug (Gotcha: the published app slug must be the PascalCase , set via an explicit — see Phase 8). : at scaffold, is EXACTLY — nothing else. is added ONLY in Phase 8 once the connector is declared OK (smoke tests passed), right before committing. NEVER add labels on your own initiative (no service name, no //, no ); platform-injected labels (, ) must be REMOVED from index.yml before any push. Extra labels only on explicit user request.
Phase 5 — API surface: registry + ops + mcpTools
From swagger.yml, generate (this mirrors reference/imports/Custom Code.yml resolveToolAction/getOperation/buildSalesforceRequest):
ENTITY_OPS: { <entity>: { <action>: <operationName> } } — group endpoints into a handful of entities, each with action verbs. Keep the tool count small (entities, not endpoints).
OPERATIONS: { <operationName>: {method, path, pathParams, queryParams, bodyParams|bodyPassthrough, rawBodyParam?, contentType?, host?/baseUrl?} }. For multi-API services include the per-op base/host.
- Per-op App-mode automation
<operationName>.yml (thin public wrapper → buildAppAuth → methodRestOp). Generate one per operationName (the reference has ~78). Keep them uniform. Output the RAW API data (output: '{{apiResult.data}}'), NOT a formatToolOutput envelope — these are App-mode instruction calls (Connector.<op>:), so callers want the plain JSON ({...}), not the MCP {content:[{type:text}]} tool-result. formatToolOutput (the MCP content envelope) belongs ONLY to the MCP tool path (routeToolCall → toolRestOp), never to the App-mode wrappers.
index.yml config.value.mcpTools (under config.value, not config.mcpTools; see Phase 4 warning + Gotcha 12): one entry per entity, with an inputSchema whose action enum lists the entity's actions + the shared params. Mirror the reference's shape. Do not use $ref in an inputSchema; keep every tool description at or below 1024 characters; give every array an items schema. Resolve swagger references during generation.
Validate the registry/op coherence: every OPERATIONS key has an automation file and vice-versa; every entity in mcpTools routes. Never invent an endpoint: every OPERATIONS path must exist in the official API docs — when the swagger was LLM-generated, spot-check the "convenience" ops (a fabricated /views/byPath or /summary returns confusing 404s that look like user errors). Canned GraphQL queries must be validated against the REAL schema (run them once) and their variables explicitly mapped from tool arg names onto the variables the query declares (extra/undeclared variables are rejected by some servers).
Phase 6 — Auth (buildAppAuth + OAuth flow + host helper + testAuth)
buildAppAuth.yml: keep the structure of reference/; replace the mode branches with the service's modes (Phase 1). Each branch returns {accessToken, baseUrl} or sets {error}. Add the targetUserId arg + the oauth cron branch (read the fingerprinted <pfx>Refresh_<userId>_<fingerprint> workspace secret via run: module: secrets, scope: workspace, exchange refresh→access on the spot, return fresh token). Keep the host helper call. ⚠️ This module: secrets read only works under a principal (interactive, or runtime-scheduled cron). It returns not_found from an EXTERNAL anonymous webhook. Do not use plaintext/Collections as a fallback: use an authenticated relay/token service or fail closed (Gotcha 18). The assumeRole: tokenResolver on that read is not optional: without it the lookup resolves under the caller, which in a user-less run holds no role and matches nothing, so the secret is there and simply invisible.
- Host rule: if the provider's OAuth/API host differs from a UI host (Salesforce lightning→my), keep
normalizeLoginHost; otherwise rename to normalizeAuthHost and adjust/no-op the rule. Provider OAuth endpoints are never on the UI/console host.
oauthConnect/Callback/Status/Disconnect: swap authorize/token/revoke URLs + scopes. Token exchange body: MUST be a YAML object (runtime serializes to form-urlencoded when Content-Type: application/x-www-form-urlencoded) — never a string. Store the user-scope token AND best-effort the workspace <pfx>Refresh_<userId> secret (works when the connecting user is workspace Editor/Owner — see Gotchas RBAC). Redirect pages use meta-refresh only (no inline <script> — CSP blocks it) to the SPA view ?view=oauthCallback&status=…; the SPA does the countdown/auto-close.
- Central platform OAuth client (token-service) — scaffold it for EVERY OAuth connector (validated on
google-workspaces; files in reference/central-oauth/):
index.yml config.value: add ONE static binding per central field (push-safe) — centralOAuthClientId: '{{secret.<camel>CentralOAuthClientId}}', centralOAuthClientSecret: '{{secret.<camel>CentralOAuthClientSecret}}', , , — each a separate CORE secret holding one plain value (merge ).
Phase 7 — Config SPA (model B)
Scaffold the SPA at the repo-root pages/<slug>/ (sibling of the DSUL-pure workspaces/<slug>/), starting from reference/pages/<slug>/. Adapt src/App.tsx:
AuthConfig/Mode/MODES/FIELDS/PREAMBLE → the service's auth modes (each PREAMBLE explains where to create the credential on the provider + the needed fields; each FIELDS entry is the per-mode form).
- Include controls for every provider/tenant/environment field from the Phase 1 inventory (including endpoints, API version, scopes/audience, connection/resource IDs). Save them only through the secret endpoint. UX placeholders may illustrate syntax but MUST use non-real reserved examples and are never runtime fallbacks.
- OAuth connectors:
oauthCentral mode FIRST + DEFAULT, and a MaintainerSetup view. Mode oauthCentral (label t('mode.oauthCentral') ≈ "Connexion OAuth2 (client de la plateforme)") exposes only an optional scopes field — zero-config, the tenant just clicks Connect; mode oauth becomes "your own client" and keeps the per-tenant callback-URL block (oauth-only — central mode needs no tenant URI registered). Add const isOAuthMode = (m) => m === 'oauth' || m === 'oauthCentral' and gate the Connect/status/disconnect buttons + the capability auth block on it. Router rule: ?view=oauthCallback → result view; maintainer view only when hosted in the connector's OWN core workspace — detect by HOST IDENTITY (const CENTRAL_SLUG = '<slug>'; props.workspace?.slug === CENTRAL_SLUG), NOT by the absence of ?workspaceId= (the legacy !readParam('workspaceId') heuristic breaks under the inline config.block mount — Gotcha 31 — where there is no query param yet the view must be the tenant ConfigApp); else tenant ConfigApp. Build EVERY maintainer/central URL from slug:${CENTRAL_SLUG} (NOT centralSlugOf(workspace), which returns the tenant slug under config.block). MaintainerSetup shows the single provider redirect URI to register (${host}/workspaces/slug:<slug>/webhooks/oauthCallback — must equal resolveOAuthClient's central redirectUri byte-for-byte), gates on the maintainerStatus webhook FIRST (Gotcha 28 — non-maintainers get an "Access restricted" card, never the form; do NOT gate on the secrets GET, it returns 200 {} for non-privileged users), prefills from (editor-gated; the central secret is intentionally readable by core editors), and saves via . See .
Phase 7.5 — Mandatory secret-backed configuration audit
Run python3 scripts/audit-secret-backed-config.py <workspace-root> [<spa-root>] (copy it from this skill's scripts/). It MUST run before validate_automation, every deploy, and every push.
The audit MUST:
- print a complete inventory of every
config.value leaf and classify it as secret binding, structural constant, or forbidden literal;
- scan versioned DSUL, imports, SPA, tests, fixtures, and docs for provider URLs, tenant/subscription UUIDs, resource IDs/groups, connection IDs, scopes/audiences, API versions, environment names/hosts, credentials, and similar literals;
- verify that sensitive/provider fields exist in
secrets.schema, are provisioned in the secret store, receive binding B + binding A, and have a SPA/install control;
- verify token caches include tenant ID + client ID + scope in their key/fingerprint and invalidate on mismatch;
- fail non-zero for every variable literal or missing link unless an exact-path/value exception has an explicit, reviewed justification in
secret-backed-audit-allowlist.yml.
The allowlist is only for structural/behavioral constants. It is not a waiver for a provider, tenant, client, resource, or environment value. Include the audit report and every remaining allowlisted literal + justification in the handoff.
Phase 8 — Deploy, publish, smoke
Deploy runbook (back changes need only a push; front changes need a bundle upload first):
0. Run the Phase 7.5 audit. Any failure blocks validation, deploy, publish, commit, and push.
-
(front) upload_file dist/bundle.js (public, uploaded as <slug>-config-bundle-<YYYYMMDD>.js — use a NEW filename each deploy, AppRenderer/browser cache aggressively) → bump index.yml config.value.bundles[<slug>].bundle AND config.block to the RELATIVE path files/<workspaceId>/<uploaded-file>.js (strip the API origin from the returned URL — e.g. bundle: files/7oQvI6Q/BPxcvrXvnzTiJzzRlB71e.sharepoint-next-config-bundle-20260804.js), never an absolute URL, so the workspace stays portable across environments. Bundle pointers are structural deployment metadata; they are the only deployment refs allowed in config.value.
-
Before pushing, inspect secrets.schema, every /security/secrets write, and automation argument schemas. Each declared secret uses type: string, every secret entry contains one plain value, and every credential- or sensitive-content-bearing argument uses secret: true. Check likely activity-feed exposures such as email content, messages, documents, attachments, personal data, provider responses, automation outputs, and manual emits. Reject object or array secret values, including serialized credential bundles.
-
Push the DSUL with push_workspace targeting workspaces/<slug>/ (version name ≤15 chars). No staging dance is needed anymore: the SPA lives at the repo-root pages/<slug>/ (a sibling of workspaces/<slug>/), so the pushed workspace folder is DSUL-pure and the importer never sees the React project — it can't choke on node_modules/pages/ or clobber the remote SPA. (Legacy note: under the old nested workspaces/<slug>/pages/<appName>/ layout you had to move pages/ out before pushing and back after — obsolete now.)
-
Publish requires the photo (Phase 3). The published app slug/id MUST be <AppSlug> (PascalCase — e.g. AzureOcr, SalesforceNext); it is the appInstance prefix in consumer webhooks. Two import-time footguns make this easy to get wrong (validated on azure-ocr 2026-06-19):
- The importer auto-publishes any
production:app-labelled workspace, deriving the store app id from the workspace VERBATIM (name → app id ). So (, not "Azure OCR") — the human label goes in . That alone makes the auto-derived id correct. ALSO call explicitly and .
Phase 9 — Knowledge Resources (optional — PROPOSE it when the service is a document/file store)
Rewritten 2026-08-19. This phase used to describe a ks* contract
(ksManifest / ksBrowse / ksListFiles / ksCall…). That contract is
gone — no connector in the repo implements it. knowledge-sync speaks
standard MCP Resources, and nothing else: resources/list,
resources/read, resources/templates/list, and one tools/call <tool>/checkAccess. If you find ks* anywhere, it is dead code.
When to propose (do it spontaneously in Phase 1, alongside the auth decision):
if the service exposes a browseable hierarchy of ingestible documents — Drive,
SharePoint/OneDrive, Confluence, a mailbox, Box, S3/Azure Blob, a DMS, a wiki —
the connector can double as a Knowledge source: knowledge-sync traverses its
Resources and indexes them into a knowledge base. Offer it: "Ce service expose
des documents navigables — je le rends aussi utilisable comme source de base de
connaissances ?". Skip it for pure action/API services with no document tree
(Salesforce records, a calendar, a payments API).
What it is: four Resource automations plus one guard, layered on the
connector's existing list/get primitives — no auth or REST rewrite. Live
references, deliberately different tree shapes: sharepoint-next
(site→drive→item, pre-authenticated downloadUrl), google-workspaces
(drive→folder, exported text), webdav (folder→file, inlined bytes),
confluence-next (space→page, flattened — see the _meta.kind rule below),
outlook-next (folder→message, two-phase cursor), azure-blob-storage
(container→prefix→blob, minted SAS). Mirror whichever is closest.
The 5 automations to add (all private: true; copy a reference then adapt):
listMcpResourceTemplates — static URI templates + the profile entry. No
tenant, no auth. The profile entry MUST carry mimeType: application/json.
readMcpResource — the profile branch first (public, returns before
anything touches credentials), then a container descriptor or a document.
listMcpResources — children of a parent URI, cursor-paginated, page size
clamped from BOTH sides (a 0 or fractional size walks the cursor forever).
checkMcpResourceAccess — bounded, deduplicated, fail-closed. See below.
requireWorkspaceRole — the authorization guard. See below.
Plus the branches in mcp.yml: resources/templates/list, the profile read,
resources/list|read, the delegated checkAccess intercept, and
serverCapabilities.resources on the MCP Core delegation.
The Knowledge profile (<scheme>://profile/knowledge, returned as JSON text):
knowledge_compatible: true, provider, tools, hierarchy, canonical_uri,
list: {parent_parameter, default_page_size, maximum_page_size}, read,
revision: {primary, fallback}, access: {tool, action, maximum_uris},
authentication: {modes, resource_access}. Discovery reads exactly this URI,
anonymously, to decide whether the connector is Knowledge-capable.
⚠️ Two silent-failure traps at discovery. initialize MUST advertise
capabilities.resources, and the profile template MUST be
mimeType: application/json. Discovery that finds neither gives up silently,
reporting an ordinary "not compatible" with no error to read — the most expensive
failure mode in the chain.
Four engine constraints, measured in _sync-engine.yml, that decide your data
model. Read them BEFORE choosing URIs:
_meta.kind has exactly two families. file is ingested; site/drive/
folder (or mimeType: inode/directory) is descended into. A node is one or
the other. When an object is genuinely both a document AND a parent — a
Confluence page, a Notion page — model it as file and flatten the tree:
modelling it as a container means it is never indexed, and every such object
silently vanishes from the base.
revision is read at the TOP LEVEL of each listed resource, next to uri
/ name / mimeType / size — not inside _meta. Put it only in _meta and
every run re-ingests everything.
- The engine sends
metadataOnly: true on the INGESTION path and prefers
bytes (_meta.downloadUrl) over inline text (contents[0].text). If your
provider has no pre-signed URL, metadataOnly must NOT strip the text —
dropping it indexes every document as its own metadata (the SharePoint defect:
ten documents of pure metadata, counted as ten successes).
- Never send both a
downloadUrl and text: Storage prefers content over
fetch_url, so sending both means the URL is never fetched.
Authorization — a workspace role, NOT the agent allowlist (platform#152):
validateAgent guards tools/call and nothing else. Nobody in a Resource flow is
an agent — Knowledge Sync browses for a signed-in human and synchronizes with no
identity at all, so no agent_id is ever injected and the allowlist answers
missing_agent_id to every legitimate call, leaving "allow EVERY agent" as the
only way to make a knowledge base work. requireWorkspaceRole asks the question
that actually bounds the call:
| Caller | Check |
|---|
An agent (agent-id header or params.agent_id present) | validateAgent, unchanged |
| A user — wizard, browse, the self-carried-connection probe | GET /workspaces/{id}/versions as the caller (no Authorization header — the runtime mints the token from source.userId) |
| No identity — the sync, scheduled or manual | run.sourceWorkspaceId ∈ config.knowledgeSync.trustedCallers |
tools/call <tool>/checkAccess | The trusted-caller guard, placed above the allowlist |
Deliberately not access-manager.checkAccess: with a resourceType and no
resourceId it never reaches the roles it is handed and resolves against the
caller's org IAM map, where only an org-wide admin passes — the tenant's own owner
is refused on their own connector.
The access check answers which of a batch of URIs one reader may open. Two
shapes, and you must say which one you shipped, in the profile and the docs:
- Per-reader (provider has user identities: SharePoint, Drive, Confluence,
a mailbox): probe each URI at the READER's identity, with
buildAppAuth(modeOverride: 'oauthCentral', targetUserId) forcing per-user
resolution — a check run on the ingestion identity answers for THAT identity and
clears documents the reader cannot open.
- Perimeter only (one service credential: WebDAV, Azure Blob): it verifies the
document is still inside the configured scope and still exists. Say plainly
that a base fed from there is exactly as readable as the people it is shared
with, and that
targetUserId is accepted then ignored.
Both: cap the batch (access.maximum_uris) and refuse above it rather than
truncate — a shortened check denies readable documents and looks like a permission
bug nobody can reproduce. Fail closed on malformed URI, denial, expired token and
provider outage alike. Tell a 401 (reconnect) apart from a 403/404 (denial).
Shared plumbing:
- Anti-impersonation guard (every delegated path): honour
body.targetUserId
ONLY when run.sourceWorkspaceId (platform-injected, unforgeable) is in
config.knowledgeSync.trustedCallers; else fall back to {{user.id}}/ambient.
Never trust an inbound targetUserId.
index.yml: config.value.knowledgeSync.trustedCallers: [Yfxb1Vv] (the
knowledge-sync host). No knowledge-connector label — that was the ks*
discovery mechanism; discovery now goes through the Capabilities catalog entry
or a self-carried connection. Add the checkAccess action to the relevant tool's
action enum, marked not for the model, with resourceUris (type: array
WITH items) and targetUserId.
- Custom Code helpers (param types string|number|boolean|object ONLY — an
array/integer/oneOf param bricks the WHOLE functions deploy in a silent
400): the URI parser, the resource mapper, pack/unpack cursor, inList,
uniq, plus whatever content extraction the provider needs (HTML→text, SAS
signing…).
Self-carried connections: an endpoint that passes the above can be attached to
a knowledge base directly — POST knowledge-sync/v1/connections with
connector.server instead of a capability_id, no catalog entry, no
mcp-api-key. knowledge-sync probes it anonymously, then follows with a
resources/list carried by the caller's identity — which is exactly the guard
above, and why anyone may probe a server while only someone allowed to read it may
attach it.
Verify (most of it needs no live third-party account): smoke initialize
(expect capabilities.resources), resources/templates/list and the profile read
with no credentials at all; then, with a connected account, resources/list
on the root and one level down, and resources/read on a document (check the text
or the downloadUrl, never both). Then create a connection in a knowledge base →
sync → the documents are indexed; re-run → nothing is re-ingested (revision
comparison). Trace runtime.automations.executed on both Yfxb1Vv and the
connector.
Gotchas (each cost hours — do not relearn them)
-
Secret refs are opaque, and each secret key stores one plain value. run: module: secrets, function: get returns a $secret: reference that decrypts only when interpolated inside a fetch (url/headers/body). You cannot read a field from it in DSUL, and Custom Code cannot decrypt it. Store each password, token, client ID, scope string, or similar value under its own secret key. Do not store JSON, arrays, mappings, or serialized credential bundles. Mark both credentials and sensitive content with secret: true when they cross automation argument boundaries; storage and activity-feed redaction are separate controls. For the cron OAuth path, exchange the refresh token on the spot.
-
RBAC on workspace secrets. A SecureSecret scope: workspace can be written only by Owner/Editor/SuperAdmin (CASL); a plain member only manages their own scope: user. The config-app connector flow is admin/editor so the workspace token write succeeds; a non-editor end-user connecting via an agent connect_url stays interactive-only. "Best-effort" is not a comment — it MUST be enforced: wrap the run: module: secrets … scope: workspace write in a try/catch (see reference/automations/oauthCallback.yml). Otherwise a non-editor end-user hits an uncaught SecretsError "create on secure_secrets" that aborts the whole callback (scary error + broken redirect) even though their user-scope token was already persisted and the agent works fine.
-
Two secret stores, don't confuse them. PATCH /workspaces/:id/security/secrets writes SubjectType.Secret (config secrets, resolved via {{secret.X}} 2-hop binding). The secrets runtime module (set/get/delete, returns refs) writes SubjectType.SecureSecret. They are DIFFERENT — don't write with one and read with the other.
-
MCP notifications → 202. mcp.yml must answer any JSON-RPC message with no id (e.g. notifications/initialized) with HTTP 202 (set $http.status: 202), not a JSON-RPC object, or the agent never registers tools and the LLM hallucinates tool names.
-
CSP blocks inline <script>. Webhook-served HTML runs under script-src 'self'. OAuth redirect pages must rely on only (+ a fallback ); never an inline . The countdown/auto-close lives in the SPA bundle (allowed), reached via the redirect.