feat(apps): refine Postman and Shopify setup (#12357)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps give those agents governed access to external tools. > - Provider catalogs must match each provider's current protocol and credential contract. > - Postman method labels and API-key placement were outdated. > - Shopify now offers a UCP commerce endpoint that needs a managed agent-profile argument. > - This pull request updates both providers and documents the complete connection-authoring workflow. > - The benefit is accurate setup, safer runtime defaults, and a repeatable provider review process. ## Linked Issues or Issue Description Refs #11965 This is stack 10 of 11. It depends on stack 9 and preserves the final catalog work recovered from #11965. Related: #5904 covers Shopify skill routing. This pull request covers the Apps connection contract instead. ## What Changed - Update Postman hosted MCP methods, capability choices, default selection, and bearer-token placement. - Add Shopify UCP commerce and Storefront compatibility methods with public-store prerequisites. - Inject the reviewed Shopify UCP agent profile at runtime and remove that managed field from user input schemas. - Classify Shopify checkout completion and cancellation as destructive actions. - Expand the connection authoring runbook from provider research through verification and pull request handoff. - Add focused shared, server, and UI coverage. - Make the approved-execution waiter phase-aware so slow preparation cannot consume the provider execution timeout and grace period. - Settle legacy pre-execute-on-approve requests and invocations as failed, clear their stale idempotency key, and allow a fresh governed approval instead of leaving work stuck in `executing`. ## Verification - `pnpm exec vitest run packages/shared/src/app-definitions.test.ts server/src/__tests__/tool-access-service.test.ts ui/src/pages/apps/AppsConnect.test.tsx -t "Postman|Shopify|normalizeConnectionMethodConfig|classifyRisk"` (16 passed) - `pnpm exec vitest run server/src/services/approved-execution-wait.test.ts` (4 passed) - `pnpm exec vitest run server/src/__tests__/tool-gateway.test.ts -t "enforces policy, approvals, retries, rate limits, and company boundaries for connected remote MCP calls"` (1 passed) - `pnpm exec vitest run server/src/__tests__/tool-gateway-service.test.ts` (21 passed; includes legacy approval settlement and fresh-approval recovery) - `pnpm --filter @paperclipai/server typecheck` - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` ## Risks - Shopify UCP calls now include a Paperclip-managed agent profile that overrides caller input at the same path. - Postman EU credentials now use the hosted MCP server's bearer-token contract instead of the general REST API header. - The catalog generator and checked-in definitions change together to prevent regeneration drift. - Approved execution preparation has an explicit two-minute bound; provider execution retains its own 65-second timeout and persistence grace starting from durable provider start. - Legacy approvals created before execute-on-approve are intentionally terminalized and must be requested again under the current signed contract. > I checked `ROADMAP.md`. This provider update does not duplicate planned core work. The related open Shopify PR addresses skill routing, not Apps connections. ## Model Used OpenAI Codex, GPT-5. The runtime exact model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
c90d904779
commit
a560b48d6d
|
|
@ -20,6 +20,12 @@ Before making changes, read in this order:
|
|||
`doc/SPEC.md` is long-horizon product context.
|
||||
`doc/SPEC-implementation.md` is the concrete V1 build contract.
|
||||
|
||||
When adding or changing an Apps catalog connection, also follow
|
||||
`doc/connections/CONNECTOR-PLAYBOOK.md`. It is the canonical connection
|
||||
authoring runbook for provider research, supported transport/auth patterns,
|
||||
credential handling, branding, implementation, testing, live proof, and PR
|
||||
submission.
|
||||
|
||||
## 3. Repo Map
|
||||
|
||||
- `server/`: Express REST API and orchestration services
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,7 @@ Paperclip code change. A curated `AppDefinition` is a **convenience layer** —
|
|||
branding, tailored fields, scoped defaults, support copy — not a prerequisite.
|
||||
|
||||
This is the documented baseline for connecting anything. Read
|
||||
[Connector playbook](./CONNECTOR-PLAYBOOK.md) when you want to add the branded
|
||||
[Connection authoring runbook](./CONNECTOR-PLAYBOOK.md) when you want to add the branded
|
||||
convenience layer on top for a vendor Paperclip should promote.
|
||||
|
||||
Accepted in the [generic remote MCP plan](/PAP/issues/PAP-17078#document-plan),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
Audience: internal engineers and product contributors working on integrations.
|
||||
|
||||
Start here when adding a provider:
|
||||
[Connection authoring runbook](./CONNECTOR-PLAYBOOK.md). It is the canonical
|
||||
agent tutorial from provider research and protocol classification through
|
||||
manifest generation, branding, secrets, deterministic tests, real-account
|
||||
proof, and PR submission.
|
||||
|
||||
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
|
||||
[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md). Optional credential custody:
|
||||
[Vercel Connect](./VERCEL-CONNECT.md).
|
||||
|
|
@ -88,11 +94,11 @@ identity-service documentation or re-deriving it.
|
|||
| Plane | Question | Lives where | Token profile |
|
||||
| --- | --- | --- | --- |
|
||||
| **P1. Sign-in methods** | *Who are you?* | `paperclip-id` (id.paperclip.ing → Account) | Minimal-scope provider tokens (`openid email profile`), used once to authenticate, encrypted at rest, never exported |
|
||||
| **P2. Connections (Apps)** | *What may your agents touch?* | Paperclip App instances (`tool_connections`), acquired via the **connect broker** for hosted + self-hosted | Rich-scope, long-lived resource tokens in the **instance's** encrypted vault; per-agent grants; ask-first on writes |
|
||||
| **P2. Connections (Apps)** | *What may your agents touch?* | Paperclip App instances (`tool_connections`), acquired via the **connect broker** for hosted + self-hosted | Rich-scope, long-lived resource tokens in the **instance's** encrypted vault; per-agent grants; risk-tier policy defaults |
|
||||
| **P3. Login with Paperclip** | *Who may authenticate against us?* | `paperclip-id` OIDC provider + DB-backed client registry | Our ES256 ID/access tokens issued *by* us to registered RPs (instances, the broker, future third parties) |
|
||||
|
||||
Everything in `doc/connections/` — the [First-30 matrix](./FIRST-30-MATRIX.md),
|
||||
the [connector playbook](./CONNECTOR-PLAYBOOK.md), and the connect-broker work —
|
||||
the [connection authoring runbook](./CONNECTOR-PLAYBOOK.md), and the connect-broker work —
|
||||
lives on **plane P2**. It never acquires, stores, or brokers a P1 sign-in token.
|
||||
|
||||
### The standing rule (D7)
|
||||
|
|
@ -163,9 +169,11 @@ not own durable tokens.
|
|||
- [Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md) is the baseline:
|
||||
how an operator connects a standards-compliant remote MCP endpoint with no
|
||||
Paperclip code change, and how sign-in resolves a client.
|
||||
- [Connector playbook](./CONNECTOR-PLAYBOOK.md) is the repeatable template for
|
||||
adding a vendor as a catalog entry on Apps v2 — the optional branded
|
||||
convenience layer over the baseline above.
|
||||
- [Connection authoring runbook](./CONNECTOR-PLAYBOOK.md) is the one
|
||||
end-to-end, agent-executable guide for adding a vendor as a catalog entry on
|
||||
Apps v2: research, connection-type selection, OAuth/API-key/generated-URL
|
||||
setup, encrypted credential handling, branding, implementation, browser and
|
||||
live-provider testing, verification, and PR submission.
|
||||
- [Vercel Connect operator guide](./VERCEL-CONNECT.md) documents the optional
|
||||
external credential source, deployment flags, runtime resolution, recovery,
|
||||
and smoke requirements.
|
||||
|
|
|
|||
|
|
@ -73,9 +73,14 @@ describe("AppDefinition catalog",()=>{
|
|||
]);
|
||||
expect(method("context7")).toMatchObject({auth:"none",defaults:{serverUrl:"https://mcp.context7.com/mcp"}});
|
||||
expect(APP_DEFINITIONS.find((app)=>app.slug==="planetscale")?.methods.map((candidate)=>candidate.key)).toEqual(["mcp-oauth","mcp-insights-only"]);
|
||||
expect(APP_DEFINITIONS.find((app)=>app.slug==="postman")?.methods.map((candidate)=>candidate.key)).toEqual([
|
||||
const postman=APP_DEFINITIONS.find((app)=>app.slug==="postman");
|
||||
expect(postman?.methods.map((candidate)=>candidate.key)).toEqual([
|
||||
"mcp-oauth-minimal","mcp-oauth-code","mcp-oauth-full","mcp-eu-key-minimal","mcp-eu-key-code","mcp-eu-key-full",
|
||||
]);
|
||||
expect(getAvailableConnectionMethod(postman!)?.key).toBe("mcp-oauth-full");
|
||||
expect(postman?.methods.filter((candidate)=>candidate.auth==="api_key").every((candidate)=>
|
||||
candidate.keyPlacement?.name==="Authorization"&&candidate.keyPlacement.prefix==="Bearer "
|
||||
)).toBe(true);
|
||||
expect(method("supabase")?.tenantFields?.find((field)=>field.key==="readOnly")?.defaultValue).toBe(false);
|
||||
expect(method("asana")?.ownershipModes).toEqual(["customer"]);
|
||||
expect(method("zapier")).toMatchObject({key:"generated-url",auth:"none",defaults:{}});
|
||||
|
|
@ -188,9 +193,9 @@ describe("AppDefinition catalog",()=>{
|
|||
]],
|
||||
]);
|
||||
});
|
||||
it("configures Shopify's official tenant-scoped Storefront MCP without OAuth",()=>{const method=APP_DEFINITIONS.find((app)=>app.slug==="shopify")?.methods[0];expect(method).toMatchObject({key:"storefront-mcp",auth:"none",defaults:{serverUrlTemplate:"https://{storeDomain}/api/mcp"},tenantFields:[expect.objectContaining({key:"storeDomain",required:true})]});expect(resolveConnectionMethodServerUrl(method!,{storeDomain:"paperclip-demo.myshopify.com"})).toBe("https://paperclip-demo.myshopify.com/api/mcp");expect(resolveConnectionMethodServerUrl(method!,{})).toBeNull()});
|
||||
it("configures Shopify's current UCP and compatibility MCP methods without OAuth",()=>{const shopify=APP_DEFINITIONS.find((app)=>app.slug==="shopify");expect(shopify?.methods.map((method)=>method.key)).toEqual(["ucp-commerce","storefront-mcp"]);const ucp=shopify?.methods[0];const compatibility=shopify?.methods[1];expect(ucp).toMatchObject({auth:"none",defaults:{serverUrlTemplate:"https://{storeDomain}/api/ucp/mcp",toolArgumentDefaults:{meta:{"ucp-agent":{profile:"https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json"}}}},tenantFields:[expect.objectContaining({key:"storeDomain",required:true})]});expect(compatibility).toMatchObject({auth:"none",defaults:{serverUrlTemplate:"https://{storeDomain}/api/mcp"}});expect(resolveConnectionMethodServerUrl(ucp!,{storeDomain:"paperclip-demo.myshopify.com"})).toBe("https://paperclip-demo.myshopify.com/api/ucp/mcp");expect(resolveConnectionMethodServerUrl(compatibility!,{storeDomain:"paperclip-demo.myshopify.com"})).toBe("https://paperclip-demo.myshopify.com/api/mcp");expect(resolveConnectionMethodServerUrl(ucp!,{})).toBeNull();expect(shopify?.setupPrerequisite).toMatchObject({title:"Launch the storefront before connecting",actionUrl:"https://admin.shopify.com/"});expect(shopify?.setupPrerequisite?.steps?.join(" ")).toContain("Storefront visibility to Public")});
|
||||
it("offers PostHog OAuth and API-key methods with zero-config defaults and advanced narrowing",()=>{const posthog=APP_DEFINITIONS.find((app)=>app.slug==="posthog");expect(posthog?.methods.map((method)=>method.key)).toEqual(["mcp-oauth","mcp-api-key"]);for(const method of posthog?.methods??[]){const projectField=method.tenantFields?.find((field)=>field.key==="projectId");expect(method.riskTier).toBe("S3");expect(method.tenantFields?.find((field)=>field.key==="readOnly")).toMatchObject({defaultValue:false,advanced:true});expect(projectField).toMatchObject({advanced:true,transport:{location:"header",name:"x-posthog-project-id"}});expect(projectField?.required).not.toBe(true);expect(method.tenantFields?.filter((field)=>field.advanced).map((field)=>field.key)).toEqual(["projectId","readOnly","features","tools"]);expect(method.tenantFields?.find((field)=>field.key==="mode")).toMatchObject({hidden:true,defaultValue:"tools",transport:{location:"query",name:"mode"}});expect(method.configRequirements).toBeUndefined();expect(method.requiredResourceFilters).toBeUndefined();expect(method.guidanceMd).toContain("optional advanced controls")}});
|
||||
it("requires only reviewed provider or safety-boundary configuration on the default path",()=>{const required=APP_DEFINITIONS.flatMap((app)=>app.methods.flatMap((method)=>[...(method.tenantFields??[]),...(method.extensionFields??[])].filter((field)=>field.required&&field.advanced!==true&&!field.hidden).map((field)=>`${app.slug}:${method.key}:${field.key}`))).sort();expect(required).toEqual(["clickhouse:mcp-oauth:serviceId","shopify:storefront-mcp:storeDomain","supabase:mcp-api-key:projectRef","supabase:mcp-oauth:projectRef"])});
|
||||
it("requires only reviewed provider or safety-boundary configuration on the default path",()=>{const required=APP_DEFINITIONS.flatMap((app)=>app.methods.flatMap((method)=>[...(method.tenantFields??[]),...(method.extensionFields??[])].filter((field)=>field.required&&field.advanced!==true&&!field.hidden).map((field)=>`${app.slug}:${method.key}:${field.key}`))).sort();expect(required).toEqual(["clickhouse:mcp-oauth:serviceId","shopify:storefront-mcp:storeDomain","shopify:ucp-commerce:storeDomain","supabase:mcp-api-key:projectRef","supabase:mcp-oauth:projectRef"])});
|
||||
it("limits Vercel Connect setup to the reviewed pilot methods",()=>{
|
||||
const reviewed=APP_DEFINITIONS.flatMap((app)=>app.methods.flatMap((method)=>method.credentialSources?.vercelConnect?[{slug:app.slug,key:method.key,review:method.credentialSources.vercelConnect}]:[]));
|
||||
expect(reviewed.map(({slug,key})=>`${slug}:${key}`).sort()).toEqual(["linear:mcp-oauth","notion:mcp-oauth","posthog:mcp-api-key","posthog:mcp-oauth"]);
|
||||
|
|
|
|||
|
|
@ -29,13 +29,18 @@
|
|||
},
|
||||
"guidanceMd": "Connect Postman in the browser. A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints.",
|
||||
"riskTier": "S3",
|
||||
"label": "US · Minimal",
|
||||
"label": "US · Browser sign-in",
|
||||
"consoleLinks": {
|
||||
"docs": "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server"
|
||||
},
|
||||
"warnings": [
|
||||
"A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints."
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "minimal",
|
||||
"label": "Minimal",
|
||||
"description": "Essential workspace, collection, and environment tools with the smallest tool catalog."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-oauth-code",
|
||||
|
|
@ -50,13 +55,18 @@
|
|||
},
|
||||
"guidanceMd": "Connect Postman in the browser. A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints.",
|
||||
"riskTier": "S3",
|
||||
"label": "US · Code",
|
||||
"label": "US · Browser sign-in",
|
||||
"consoleLinks": {
|
||||
"docs": "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server"
|
||||
},
|
||||
"warnings": [
|
||||
"A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints."
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "code",
|
||||
"label": "Code",
|
||||
"description": "Tools for generating client code from API definitions."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-oauth-full",
|
||||
|
|
@ -71,13 +81,18 @@
|
|||
},
|
||||
"guidanceMd": "Connect Postman in the browser. A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints.",
|
||||
"riskTier": "S3",
|
||||
"label": "US · Full",
|
||||
"label": "US · Browser sign-in",
|
||||
"consoleLinks": {
|
||||
"docs": "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server"
|
||||
},
|
||||
"warnings": [
|
||||
"A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints."
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "write",
|
||||
"label": "Full",
|
||||
"description": "All Postman API tools, including write-capable collaboration and advanced features."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-eu-key-minimal",
|
||||
|
|
@ -92,7 +107,7 @@
|
|||
},
|
||||
"guidanceMd": "Use a customer-created Postman key. A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints.",
|
||||
"riskTier": "S3",
|
||||
"label": "EU · Minimal",
|
||||
"label": "EU · API key",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
|
|
@ -105,8 +120,8 @@
|
|||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "X-API-Key",
|
||||
"prefix": null
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"consoleLinks": {
|
||||
"keys": "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server",
|
||||
|
|
@ -114,7 +129,12 @@
|
|||
},
|
||||
"warnings": [
|
||||
"A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints."
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "minimal",
|
||||
"label": "Minimal",
|
||||
"description": "Essential workspace, collection, and environment tools with the smallest tool catalog."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-eu-key-code",
|
||||
|
|
@ -129,7 +149,7 @@
|
|||
},
|
||||
"guidanceMd": "Use a customer-created Postman key. A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints.",
|
||||
"riskTier": "S3",
|
||||
"label": "EU · Code",
|
||||
"label": "EU · API key",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
|
|
@ -142,8 +162,8 @@
|
|||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "X-API-Key",
|
||||
"prefix": null
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"consoleLinks": {
|
||||
"keys": "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server",
|
||||
|
|
@ -151,7 +171,12 @@
|
|||
},
|
||||
"warnings": [
|
||||
"A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints."
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "code",
|
||||
"label": "Code",
|
||||
"description": "Tools for generating client code from API definitions."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-eu-key-full",
|
||||
|
|
@ -166,7 +191,7 @@
|
|||
},
|
||||
"guidanceMd": "Use a customer-created Postman key. A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints.",
|
||||
"riskTier": "S3",
|
||||
"label": "EU · Full",
|
||||
"label": "EU · API key",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
|
|
@ -179,8 +204,8 @@
|
|||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "X-API-Key",
|
||||
"prefix": null
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"consoleLinks": {
|
||||
"keys": "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server",
|
||||
|
|
@ -188,7 +213,12 @@
|
|||
},
|
||||
"warnings": [
|
||||
"A Postman account; OAuth is available for US endpoints and API keys are required for EU endpoints."
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "write",
|
||||
"label": "Full",
|
||||
"description": "All Postman API tools, including write-capable collaboration and advanced features."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,23 +11,31 @@
|
|||
"logoUrl": "/brands/apps/shopify.svg"
|
||||
},
|
||||
"urlPatterns": [
|
||||
"https://*.myshopify.com/api/ucp/mcp",
|
||||
"https://*.myshopify.com/api/mcp"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "storefront-mcp",
|
||||
"key": "ucp-commerce",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "none",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use a store's public myshopify.com domain. No Shopify app or OAuth registration is required.",
|
||||
"whenToUse": "Recommended for Shopify's current UCP catalog, cart, and checkout tools.",
|
||||
"defaults": {
|
||||
"serverUrlTemplate": "https://{storeDomain}/api/mcp"
|
||||
"serverUrlTemplate": "https://{storeDomain}/api/ucp/mcp",
|
||||
"toolArgumentDefaults": {
|
||||
"meta": {
|
||||
"ucp-agent": {
|
||||
"profile": "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"guidanceMd": "Connect Shopify's official Storefront MCP server for shopper-facing catalog, policy, and cart tools.",
|
||||
"guidanceMd": "Connect Shopify's current UCP server for shopper-facing catalog and commerce tools. Paperclip supplies the required agent profile automatically.",
|
||||
"riskTier": "S3",
|
||||
"label": "Connect a Shopify storefront",
|
||||
"label": "Shopify UCP commerce",
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "storeDomain",
|
||||
|
|
@ -35,7 +43,47 @@
|
|||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "your-store.myshopify.com",
|
||||
"helperMd": "Enter the full myshopify.com domain without https://.",
|
||||
"helperMd": "Enter the permanent myshopify.com domain without https://. Custom storefront domains are not the MCP endpoint.",
|
||||
"validation": {
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9-]*\\.myshopify\\.com$",
|
||||
"maxLength": 255
|
||||
}
|
||||
}
|
||||
],
|
||||
"consoleLinks": {
|
||||
"docs": "https://shopify.dev/docs/agents/catalog/storefront-catalog"
|
||||
},
|
||||
"warnings": [
|
||||
"This is Shopify's shopper-facing UCP server, not Admin API access. It does not manage merchant products or customers.",
|
||||
"The storefront must be public. A private or password-protected storefront returns HTTP 401 even when the merchant is signed in to Shopify Admin.",
|
||||
"Paperclip currently uses Shopify's documented hosted agent-profile fixture while Paperclip's production UCP profile is being established."
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"store"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "storefront-mcp",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "none",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use Shopify's compatibility server when agents need storefront policy and FAQ search.",
|
||||
"defaults": {
|
||||
"serverUrlTemplate": "https://{storeDomain}/api/mcp"
|
||||
},
|
||||
"guidanceMd": "Connect Shopify's official Storefront MCP server for shopper-facing catalog, policy, and cart tools.",
|
||||
"riskTier": "S3",
|
||||
"label": "Storefront policies and compatibility tools",
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "storeDomain",
|
||||
"label": "Store domain",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "your-store.myshopify.com",
|
||||
"helperMd": "Enter the permanent myshopify.com domain without https://. Custom storefront domains are not the MCP endpoint.",
|
||||
"validation": {
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9-]*\\.myshopify\\.com$",
|
||||
"maxLength": 255
|
||||
|
|
@ -47,11 +95,23 @@
|
|||
},
|
||||
"warnings": [
|
||||
"This is Shopify's Storefront MCP, not Admin API access. It does not manage merchant products, orders, or customers.",
|
||||
"The storefront must be publicly reachable. Password-protected or restricted trial stores can return HTTP 401."
|
||||
"The storefront must be public. A private or password-protected storefront returns HTTP 401 even when the merchant is signed in to Shopify Admin."
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"store"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"docsUrl": "https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront",
|
||||
"setupPrerequisite": {
|
||||
"title": "Launch the storefront before connecting",
|
||||
"description": "Shopify's Storefront MCP is a public, no-auth endpoint. Paperclip cannot use the merchant's Shopify Admin session to bypass a private storefront.",
|
||||
"steps": [
|
||||
"Select a Shopify plan; Shopify keeps trial storefronts private until a plan is selected.",
|
||||
"In Shopify Admin, open Online Store → Preferences and set Storefront visibility to Public (remove password protection).",
|
||||
"Use the permanent <store>.myshopify.com domain in Paperclip, even if the store also has a custom domain."
|
||||
],
|
||||
"actionLabel": "Open Shopify Admin",
|
||||
"actionUrl": "https://admin.shopify.com/"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionTransp
|
|||
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
|
||||
export type OAuthRedirectConstraints = "https-or-loopback-http";
|
||||
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
|
||||
export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"}}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
|
||||
export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
|
||||
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
|
||||
|
||||
export type SelfServeMcpAuthMode =
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
|
|||
try{return new URL(value).protocol==="https:";}catch{return false;}
|
||||
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
|
||||
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
|
||||
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy==="paperclip_id_connector"&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip ID connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&v.oauthStrategy!=="paperclip_id_connector")c.addIssue({code:"custom",message:"connectorProfile requires the Paperclip ID OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})});
|
||||
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy==="paperclip_id_connector"&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip ID connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&v.oauthStrategy!=="paperclip_id_connector")c.addIssue({code:"custom",message:"connectorProfile requires the Paperclip ID OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})});
|
||||
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
|
||||
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const apps=[
|
|||
["linear","Linear","Create, update, and read Linear issues.","productivity","linear.app",["https://mcp.linear.app/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.linear.app/mcp",authorizationEndpoint:"https://linear.app/oauth/authorize",tokenEndpoint:"https://api.linear.app/oauth/token",scopesHint:["read","write"]},"S2","Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",{ownershipModes:["customer"],requiredResourceFilters:["workspace","team","project"],...vercelConnect("linear","user",["read","write"])})],
|
||||
["google-sheets","Google Sheets","Read and update selected spreadsheets.","data","sheets.google.com",["https://docs.google.com/spreadsheets/*","https://sheets.google.com/*"],method("local","local_stdio","none",{templateKey:"paperclip.google-sheets"},"S3","Share each spreadsheet with the Paperclip robot email, then paste the sheet links.",{requiredResourceFilters:["spreadsheet"]})],
|
||||
["context7","Context7","Look up current documentation for software libraries.","developer","context7.com",["https://mcp.context7.com/*"],method("mcp","mcp_remote","none",{serverUrl:"https://mcp.context7.com/mcp"},"S1","Connect Context7 to give agents current library documentation.")],
|
||||
["shopify","Shopify","Search a store's products and policies, and manage shopping carts.","commerce","shopify.com",["https://*.myshopify.com/api/mcp"],method("storefront-mcp","mcp_remote","none",{serverUrlTemplate:"https://{storeDomain}/api/mcp"},"S3","Connect Shopify's official Storefront MCP server for shopper-facing catalog, policy, and cart tools.",{label:"Connect a Shopify storefront",whenToUse:"Use a store's public myshopify.com domain. No Shopify app or OAuth registration is required.",tenantFields:[{key:"storeDomain",label:"Store domain",type:"text",required:true,placeholder:"your-store.myshopify.com",helperMd:"Enter the full myshopify.com domain without https://.",validation:{pattern:"^[A-Za-z0-9][A-Za-z0-9-]*\\.myshopify\\.com$",maxLength:255}}],consoleLinks:{docs:"https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront"},warnings:["This is Shopify's Storefront MCP, not Admin API access. It does not manage merchant products, orders, or customers.","The storefront must be publicly reachable. Password-protected or restricted trial stores can return HTTP 401."],requiredResourceFilters:["store"]})],
|
||||
["shopify","Shopify","Search a store's products and policies, and manage shopping carts.","commerce","shopify.com",["https://*.myshopify.com/api/ucp/mcp","https://*.myshopify.com/api/mcp"],[method("ucp-commerce","mcp_remote","none",{serverUrlTemplate:"https://{storeDomain}/api/ucp/mcp",toolArgumentDefaults:{meta:{"ucp-agent":{profile:"https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json"}}}},"S3","Connect Shopify's current UCP server for shopper-facing catalog and commerce tools. Paperclip supplies the required agent profile automatically.",{label:"Shopify UCP commerce",whenToUse:"Recommended for Shopify's current UCP catalog, cart, and checkout tools.",tenantFields:[{key:"storeDomain",label:"Store domain",type:"text",required:true,placeholder:"your-store.myshopify.com",helperMd:"Enter the permanent myshopify.com domain without https://. Custom storefront domains are not the MCP endpoint.",validation:{pattern:"^[A-Za-z0-9][A-Za-z0-9-]*\\.myshopify\\.com$",maxLength:255}}],consoleLinks:{docs:"https://shopify.dev/docs/agents/catalog/storefront-catalog"},warnings:["This is Shopify's shopper-facing UCP server, not Admin API access. It does not manage merchant products or customers.","The storefront must be public. A private or password-protected storefront returns HTTP 401 even when the merchant is signed in to Shopify Admin.","Paperclip currently uses Shopify's documented hosted agent-profile fixture while Paperclip's production UCP profile is being established."],requiredResourceFilters:["store"]}),method("storefront-mcp","mcp_remote","none",{serverUrlTemplate:"https://{storeDomain}/api/mcp"},"S3","Connect Shopify's official Storefront MCP server for shopper-facing catalog, policy, and cart tools.",{label:"Storefront policies and compatibility tools",whenToUse:"Use Shopify's compatibility server when agents need storefront policy and FAQ search.",tenantFields:[{key:"storeDomain",label:"Store domain",type:"text",required:true,placeholder:"your-store.myshopify.com",helperMd:"Enter the permanent myshopify.com domain without https://. Custom storefront domains are not the MCP endpoint.",validation:{pattern:"^[A-Za-z0-9][A-Za-z0-9-]*\\.myshopify\\.com$",maxLength:255}}],consoleLinks:{docs:"https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront"},warnings:["This is Shopify's Storefront MCP, not Admin API access. It does not manage merchant products, orders, or customers.","The storefront must be public. A private or password-protected storefront returns HTTP 401 even when the merchant is signed in to Shopify Admin."],requiredResourceFilters:["store"]})],{docsUrl:"https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront",setupPrerequisite:{title:"Launch the storefront before connecting",description:"Shopify's Storefront MCP is a public, no-auth endpoint. Paperclip cannot use the merchant's Shopify Admin session to bypass a private storefront.",steps:["Select a Shopify plan; Shopify keeps trial storefronts private until a plan is selected.","In Shopify Admin, open Online Store → Preferences and set Storefront visibility to Public (remove password protection).","Use the permanent <store>.myshopify.com domain in Paperclip, even if the store also has a custom domain."],actionLabel:"Open Shopify Admin",actionUrl:"https://admin.shopify.com/"}}],
|
||||
["composio","Composio","Connect Composio so Paperclip can discover and manage the toolkits in your project.","productivity","composio.dev",["https://backend.composio.dev/*"],method("api-key","rest_api","api_key",{serviceHost:"backend.composio.dev"},"S3","Create a scoped project API key in Composio. It needs read access to toolkits and auth configs; later service-connection phases also need connected-account and session access.",{whenToUse:"Use a project API key from the Composio project that owns the toolkits and connected accounts.",credentialFields:[field("apiKey","Composio project API key","Paste the Composio API key")],keyPlacement:{location:"header",name:"x-api-key"},consoleLinks:{keys:"https://app.composio.dev/",settings:"https://app.composio.dev/",docs:"https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions"}}),{featured:true}],
|
||||
["oauth-generic","OAuth app","Connect a provider using your own OAuth client.","other","oauth.net",[],method("oauth","rest_api","oauth",{},"S3","Register an OAuth client with the provider and add Paperclip's redirect URI.",{credentialFields:[{...field("clientId","Client ID","Paste the client ID"),type:"text",secret:false},field("clientSecret","Client secret","Paste the client secret")]})],
|
||||
["api-key-generic","API key app","Connect an API using a key from your provider.","other","openapis.org",[],method("api-key","rest_api","api_key",{},"S3","Create a restricted API key and paste it here.",{credentialFields:[field("apiKey","API key","Paste the API key")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})],
|
||||
|
|
@ -56,7 +56,9 @@ const apiKeySpec={
|
|||
mem0:{name:"Authorization",prefix:"Bearer ",placeholder:"m0sk_..."},
|
||||
oreilly:{name:"Authorization",prefix:"Bearer ",placeholder:"Paste your O'Reilly API token"},
|
||||
pagerduty:{name:"Authorization",prefix:"Token token=",placeholder:"Paste your PagerDuty user API token"},
|
||||
postman:{name:"X-API-Key",prefix:null,placeholder:"PMAK-..."},
|
||||
// Postman's general REST API examples use X-API-Key, but its hosted MCP
|
||||
// server explicitly expects the key as an Authorization bearer token.
|
||||
postman:{name:"Authorization",prefix:"Bearer ",placeholder:"PMAK-..."},
|
||||
razorpay:{name:"Authorization",prefix:"Basic ",placeholder:"Paste the base64-encoded key ID and secret"},
|
||||
sanity:{name:"Authorization",prefix:"Bearer ",placeholder:"sk..."},
|
||||
similarweb:{name:"api-key",prefix:null,placeholder:"Paste your Similarweb API key"},
|
||||
|
|
@ -82,12 +84,12 @@ const specialMethodsFor=(entry)=>{
|
|||
oauthMethodFor(entry,"mcp-insights-only","https://mcp.pscale.dev/mcp/planetscale-insights-only",{label:"Insights only",whenToUse:"Use query insights and schema recommendations without query execution tools.",requiredResourceFilters:["organization","database","branch"]}),
|
||||
];
|
||||
if(entry.slug==="postman") return [
|
||||
oauthMethodFor(entry,"mcp-oauth-minimal","https://mcp.postman.com/minimal",{label:"US · Minimal"}),
|
||||
oauthMethodFor(entry,"mcp-oauth-code","https://mcp.postman.com/code",{label:"US · Code"}),
|
||||
oauthMethodFor(entry,"mcp-oauth-full","https://mcp.postman.com/mcp",{label:"US · Full"}),
|
||||
apiKeyMethodFor(entry,"mcp-eu-key-minimal","https://mcp.eu.postman.com/minimal",{label:"EU · Minimal"}),
|
||||
apiKeyMethodFor(entry,"mcp-eu-key-code","https://mcp.eu.postman.com/code",{label:"EU · Code"}),
|
||||
apiKeyMethodFor(entry,"mcp-eu-key-full","https://mcp.eu.postman.com/mcp",{label:"EU · Full"}),
|
||||
oauthMethodFor(entry,"mcp-oauth-minimal","https://mcp.postman.com/minimal",{label:"US · Browser sign-in",capabilityProfile:{key:"minimal",label:"Minimal",description:"Essential workspace, collection, and environment tools with the smallest tool catalog."}}),
|
||||
oauthMethodFor(entry,"mcp-oauth-code","https://mcp.postman.com/code",{label:"US · Browser sign-in",capabilityProfile:{key:"code",label:"Code",description:"Tools for generating client code from API definitions."}}),
|
||||
oauthMethodFor(entry,"mcp-oauth-full","https://mcp.postman.com/mcp",{label:"US · Browser sign-in",capabilityProfile:{key:"write",label:"Full",description:"All Postman API tools, including write-capable collaboration and advanced features."}}),
|
||||
apiKeyMethodFor(entry,"mcp-eu-key-minimal","https://mcp.eu.postman.com/minimal",{label:"EU · API key",capabilityProfile:{key:"minimal",label:"Minimal",description:"Essential workspace, collection, and environment tools with the smallest tool catalog."}}),
|
||||
apiKeyMethodFor(entry,"mcp-eu-key-code","https://mcp.eu.postman.com/code",{label:"EU · API key",capabilityProfile:{key:"code",label:"Code",description:"Tools for generating client code from API definitions."}}),
|
||||
apiKeyMethodFor(entry,"mcp-eu-key-full","https://mcp.eu.postman.com/mcp",{label:"EU · API key",capabilityProfile:{key:"write",label:"Full",description:"All Postman API tools, including write-capable collaboration and advanced features."}}),
|
||||
];
|
||||
if(entry.slug==="pagerduty") return [
|
||||
apiKeyMethodFor(entry,"mcp-api-key-us","https://mcp.pagerduty.com/mcp",{label:"US service region"}),
|
||||
|
|
|
|||
|
|
@ -1126,6 +1126,56 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("wakes with fresh-approval instructions after an accepted tool action expires", async () => {
|
||||
const approveToolActionRequest = vi.fn().mockResolvedValue({
|
||||
status: "expired",
|
||||
error: "Managed arguments changed after review",
|
||||
});
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
interaction: {
|
||||
id: "interaction-tool-action-expired",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the action?",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
actionRequestId: "action-request-expired",
|
||||
toolName: "shopify_update_product",
|
||||
},
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
},
|
||||
createdIssues: [],
|
||||
});
|
||||
const app = await createApp(undefined, { approveToolActionRequest });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-tool-action-expired/accept")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
toolAction: {
|
||||
toolName: "shopify_update_product",
|
||||
actionRequestId: "action-request-expired",
|
||||
decision: "accepted",
|
||||
executionStatus: "expired",
|
||||
error: "Managed arguments changed after review",
|
||||
instructions: "the approved shopify_update_product action expired before execution: Managed arguments changed after review; if the task still requires it, call the tool again to request a fresh approval.",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects client-supplied tool-action metadata on interaction creation", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,13 @@ import {
|
|||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from "../services/tool-access.js";
|
||||
import {
|
||||
classifyRisk,
|
||||
normalizeConnectionMethodConfig,
|
||||
projectConnectionMethodToolInputSchema,
|
||||
projectConnectionMethodToolArguments,
|
||||
toolAccessService,
|
||||
} from "../services/tool-access.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
|
@ -10972,16 +10978,24 @@ describe("classifyRisk", () => {
|
|||
expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write");
|
||||
expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive");
|
||||
});
|
||||
|
||||
it("keeps Shopify checkout completion and cancellation behind destructive-action approval", () => {
|
||||
expect(classifyRisk({ name: "cancel_cart" }, "shopify")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "cancel_checkout" }, "shopify")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "complete_checkout" }, "shopify")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "create_cart" }, "shopify")).toBe("write");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeConnectionMethodConfig", () => {
|
||||
const posthog = getConnectableAppDefinition("posthog")!;
|
||||
const apiKeyMethod = posthog.methods.find((method) => method.key === "mcp-api-key")!;
|
||||
const clickhouseMethod = getConnectableAppDefinition("clickhouse")!.methods[0]!;
|
||||
const shopifyMethod = getConnectableAppDefinition("shopify")!.methods[0]!;
|
||||
const shopifyMethods = getConnectableAppDefinition("shopify")!.methods;
|
||||
const shopifyMethod = shopifyMethods.find((method) => method.key === "storefront-mcp")!;
|
||||
const shopifyUcpMethod = shopifyMethods.find((method) => method.key === "ucp-commerce")!;
|
||||
|
||||
it("builds a concrete Shopify endpoint from the validated store domain", () => {
|
||||
const shopifyMethod = getConnectableAppDefinition("shopify")!.methods[0]!;
|
||||
expect(normalizeConnectionMethodConfig(shopifyMethod, {
|
||||
storeDomain: "paperclip-demo.myshopify.com",
|
||||
})).toEqual({
|
||||
|
|
@ -11045,5 +11059,53 @@ describe("normalizeConnectionMethodConfig", () => {
|
|||
expect(() => normalizeConnectionMethodConfig(shopifyMethod, {
|
||||
storeDomain: "shop.myshopify.com@example.com",
|
||||
})).toThrow("Store domain has an invalid value");
|
||||
expect(normalizeConnectionMethodConfig(shopifyUcpMethod, {
|
||||
storeDomain: "rcvbsa-pz.myshopify.com",
|
||||
})).toMatchObject({
|
||||
url: "https://rcvbsa-pz.myshopify.com/api/ucp/mcp",
|
||||
});
|
||||
expect(projectConnectionMethodToolArguments(shopifyUcpMethod, {
|
||||
catalog: { query: "shirts" },
|
||||
meta: {
|
||||
caller: "kept",
|
||||
"ucp-agent": { profile: "https://attacker.example/profile.json" },
|
||||
},
|
||||
})).toEqual({
|
||||
catalog: { query: "shirts" },
|
||||
meta: {
|
||||
caller: "kept",
|
||||
"ucp-agent": {
|
||||
profile: "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(projectConnectionMethodToolInputSchema(shopifyUcpMethod, {
|
||||
type: "object",
|
||||
required: ["meta", "catalog"],
|
||||
properties: {
|
||||
meta: {
|
||||
type: "object",
|
||||
required: ["ucp-agent", "idempotency-key"],
|
||||
properties: {
|
||||
"ucp-agent": { type: "object" },
|
||||
"idempotency-key": { type: "string" },
|
||||
},
|
||||
},
|
||||
catalog: { type: "object" },
|
||||
},
|
||||
})).toEqual({
|
||||
type: "object",
|
||||
required: ["meta", "catalog"],
|
||||
properties: {
|
||||
meta: {
|
||||
type: "object",
|
||||
required: ["idempotency-key"],
|
||||
properties: {
|
||||
"idempotency-key": { type: "string" },
|
||||
},
|
||||
},
|
||||
catalog: { type: "object" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -523,6 +523,139 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
|||
expect(approved.status).toBe("approved");
|
||||
const [parkedInvocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, invocation.id));
|
||||
expect(parkedInvocation.status).toBe("awaiting_approval");
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: "mcp-remote-fixture:update_note",
|
||||
parameters,
|
||||
})).rejects.toMatchObject({ reasonCode: "legacy_approved_action_inert" });
|
||||
|
||||
const [settledRequest] = await db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
const [settledInvocation] = await db
|
||||
.select()
|
||||
.from(toolInvocations)
|
||||
.where(eq(toolInvocations.id, invocation.id));
|
||||
expect(settledRequest.status).toBe("failed");
|
||||
expect(settledInvocation.status).toBe("failed");
|
||||
expect(settledInvocation.errorCode).toBe("legacy_approved_action_inert");
|
||||
expect(settledInvocation.idempotencyKey).toBeNull();
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: "mcp-remote-fixture:update_note",
|
||||
parameters,
|
||||
})).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
expect(await db.select().from(toolActionRequests)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not let a stale legacy consumer overwrite the winning approved execution", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
|
||||
let observeLegacyClaim!: () => void;
|
||||
const legacyClaimObserved = new Promise<void>((resolve) => {
|
||||
observeLegacyClaim = resolve;
|
||||
});
|
||||
let releaseLegacyClaim!: () => void;
|
||||
const legacyClaimBlocked = new Promise<void>((resolve) => {
|
||||
releaseLegacyClaim = resolve;
|
||||
});
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
beforeLegacyApprovedActionClaim: async () => {
|
||||
observeLegacyClaim();
|
||||
await legacyClaimBlocked;
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const parameters = { noteId: "n1", body: "legacy race" };
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: "mcp-remote-fixture:update_note",
|
||||
parameters,
|
||||
})).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [actionRequest] = await db.select().from(toolActionRequests);
|
||||
const [invocation] = await db.select().from(toolInvocations);
|
||||
const currentSignature = actionRequest.signedArguments!;
|
||||
const legacySignature = signToolArguments({
|
||||
invocationId: invocation.id,
|
||||
toolName: invocation.toolName,
|
||||
canonicalArguments: canonicalToolArguments(parameters),
|
||||
signingSecret: testToolActionSigningSecret,
|
||||
});
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({ signedArguments: legacySignature })
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
await gateway.approveActionRequest({
|
||||
companyId: company.id,
|
||||
actionRequestId: actionRequest.id,
|
||||
actor: { userId: "board-user" },
|
||||
});
|
||||
|
||||
const staleAttempt = gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: "mcp-remote-fixture:update_note",
|
||||
approvedActionRequestId: actionRequest.id,
|
||||
parameters,
|
||||
}).then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error }),
|
||||
);
|
||||
await legacyClaimObserved;
|
||||
|
||||
// Simulate a concurrent repair that restores the current signed envelope
|
||||
// after the stale consumer has read the legacy envelope but before it owns
|
||||
// the approved -> executing claim.
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({ signedArguments: currentSignature, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, actionRequest.id),
|
||||
eq(toolActionRequests.status, "approved"),
|
||||
));
|
||||
const winner = await gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: "mcp-remote-fixture:update_note",
|
||||
approvedActionRequestId: actionRequest.id,
|
||||
parameters,
|
||||
});
|
||||
expect(winner.status).toBe("completed");
|
||||
|
||||
releaseLegacyClaim();
|
||||
const stale = await staleAttempt;
|
||||
expect(stale).toMatchObject({
|
||||
status: "rejected",
|
||||
error: { reasonCode: "action_already_consumed" },
|
||||
});
|
||||
|
||||
const [settledRequest] = await db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
const [settledInvocation] = await db
|
||||
.select()
|
||||
.from(toolInvocations)
|
||||
.where(eq(toolInvocations.id, invocation.id));
|
||||
expect(settledRequest.status).toBe("executed");
|
||||
expect(settledInvocation).toMatchObject({
|
||||
status: "succeeded",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(settledInvocation.idempotencyKey).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not leave unsigned action requests pending when signing is unavailable", async () => {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ import {
|
|||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import {
|
||||
canonicalToolArguments,
|
||||
readSignedToolArgumentsPayload,
|
||||
signToolArguments,
|
||||
summarizeToolValue,
|
||||
} from "../services/tool-content-guards.js";
|
||||
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
|
||||
import type { ComposioClient } from "../services/composio.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
|
@ -2657,6 +2663,307 @@ rl.on("line", (line) => {
|
|||
}
|
||||
});
|
||||
|
||||
it("expires legacy managed-connector approvals before provider dispatch", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: fakeRequest.body?.id,
|
||||
result: { content: [{ type: "text", text: "should not run" }] },
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
||||
applicationKey: "shopify",
|
||||
toolName: "kv_set",
|
||||
url: fake.url,
|
||||
connectionConfig: {
|
||||
sourceTemplateKey: "shopify",
|
||||
connectionMethodKey: "ucp-commerce",
|
||||
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
||||
},
|
||||
});
|
||||
const toolName = expectedConnectedToolName({
|
||||
applicationKey: remoteTool.application.applicationKey,
|
||||
connectionId: remoteTool.connection.id,
|
||||
toolName: remoteTool.catalogEntry.toolName,
|
||||
});
|
||||
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review managed Shopify writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { connectionId: remoteTool.connection.id },
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
await gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: toolName,
|
||||
parameters: { key: "legacy", value: "reviewed" },
|
||||
}).then(
|
||||
() => {
|
||||
throw new Error("Expected managed Shopify call to require approval");
|
||||
},
|
||||
(error) => expectGatewayError(error, 409, "approval_required"),
|
||||
);
|
||||
|
||||
const [actionRequest] = await db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.companyId, company.id));
|
||||
const signedPayload = readSignedToolArgumentsPayload({
|
||||
signedArguments: actionRequest.signedArguments,
|
||||
invocationId: actionRequest.invocationId,
|
||||
toolName,
|
||||
signingSecret: testToolActionSigningSecret,
|
||||
});
|
||||
expect(signedPayload?.executionOnApprove).toBe(true);
|
||||
|
||||
// Model an approval signed before Shopify's UCP agent profile became a
|
||||
// required managed argument. Its signature and hash are valid for that
|
||||
// historical payload, but it is no longer compatible with dispatch.
|
||||
const legacyParameters = { key: "legacy", value: "reviewed" };
|
||||
const legacyCanonical = canonicalToolArguments(legacyParameters);
|
||||
const legacySummary = summarizeToolValue(legacyParameters);
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({
|
||||
signedArguments: signToolArguments({
|
||||
invocationId: actionRequest.invocationId,
|
||||
toolName,
|
||||
canonicalArguments: legacyCanonical,
|
||||
approvalSnapshot: signedPayload?.approvalSnapshot,
|
||||
executionOnApprove: true,
|
||||
signingSecret: testToolActionSigningSecret,
|
||||
}),
|
||||
canonicalArgumentsHash: legacySummary.sha256,
|
||||
canonicalArgumentsSummary: legacySummary,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
|
||||
await expect(gateway.approveActionRequest({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
interactionId: actionRequest.interactionId!,
|
||||
actionRequestId: actionRequest.id,
|
||||
actor: { agentId: agent.id },
|
||||
})).resolves.toMatchObject({ status: "expired" });
|
||||
|
||||
expect(fake.requests).toHaveLength(0);
|
||||
const [expiredRequest] = await db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
expect(expiredRequest.status).toBe("expired");
|
||||
const [failedInvocation] = await db
|
||||
.select()
|
||||
.from(toolInvocations)
|
||||
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
||||
expect(failedInvocation).toMatchObject({
|
||||
status: "failed",
|
||||
approvalState: "expired",
|
||||
errorCode: "approved_tool_managed_arguments_changed",
|
||||
});
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not expire a managed-connector provider execution already in flight", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: fakeRequest.body?.id,
|
||||
result: { content: [{ type: "text", text: "concurrent execution won" }] },
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
||||
applicationKey: "shopify",
|
||||
toolName: "kv_set",
|
||||
url: fake.url,
|
||||
connectionConfig: {
|
||||
sourceTemplateKey: "shopify",
|
||||
connectionMethodKey: "ucp-commerce",
|
||||
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
||||
},
|
||||
});
|
||||
const toolName = expectedConnectedToolName({
|
||||
applicationKey: remoteTool.application.applicationKey,
|
||||
connectionId: remoteTool.connection.id,
|
||||
toolName: remoteTool.catalogEntry.toolName,
|
||||
});
|
||||
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review raced managed Shopify writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { connectionId: remoteTool.connection.id },
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
let driftExpiryReached!: () => void;
|
||||
const driftExpiryStarted = new Promise<void>((resolve) => {
|
||||
driftExpiryReached = resolve;
|
||||
});
|
||||
let resumeDriftExpiry!: () => void;
|
||||
const driftExpiryResume = new Promise<void>((resolve) => {
|
||||
resumeDriftExpiry = resolve;
|
||||
});
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
beforeManagedArgumentDriftExpiry: async () => {
|
||||
driftExpiryReached();
|
||||
await driftExpiryResume;
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
await gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: toolName,
|
||||
parameters: { key: "raced", value: "reviewed" },
|
||||
}).then(
|
||||
() => {
|
||||
throw new Error("Expected managed Shopify call to require approval");
|
||||
},
|
||||
(error) => expectGatewayError(error, 409, "approval_required"),
|
||||
);
|
||||
|
||||
const [actionRequest] = await db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.companyId, company.id));
|
||||
const signedPayload = readSignedToolArgumentsPayload({
|
||||
signedArguments: actionRequest.signedArguments,
|
||||
invocationId: actionRequest.invocationId,
|
||||
toolName,
|
||||
signingSecret: testToolActionSigningSecret,
|
||||
});
|
||||
const legacyParameters = { key: "raced", value: "reviewed" };
|
||||
const legacyCanonical = canonicalToolArguments(legacyParameters);
|
||||
const legacySummary = summarizeToolValue(legacyParameters);
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({
|
||||
signedArguments: signToolArguments({
|
||||
invocationId: actionRequest.invocationId,
|
||||
toolName,
|
||||
canonicalArguments: legacyCanonical,
|
||||
approvalSnapshot: signedPayload?.approvalSnapshot,
|
||||
executionOnApprove: true,
|
||||
signingSecret: testToolActionSigningSecret,
|
||||
}),
|
||||
canonicalArgumentsHash: legacySummary.sha256,
|
||||
canonicalArgumentsSummary: legacySummary,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
|
||||
const approvedAt = new Date();
|
||||
await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ status: "accepted", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
|
||||
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "approved", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({ approvalState: "approved", updatedAt: approvedAt })
|
||||
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
||||
|
||||
const retrying = gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: toolName,
|
||||
parameters: legacyParameters,
|
||||
approvedActionRequestId: actionRequest.id,
|
||||
});
|
||||
await driftExpiryStarted;
|
||||
|
||||
const executingAt = new Date();
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "executing", updatedAt: executingAt })
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
status: "executing",
|
||||
approvalState: "approved",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
startedAt: executingAt,
|
||||
completedAt: null,
|
||||
updatedAt: executingAt,
|
||||
})
|
||||
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
||||
resumeDriftExpiry();
|
||||
|
||||
await retrying.then(
|
||||
() => {
|
||||
throw new Error("Expected the stale approved retry to request a new approval");
|
||||
},
|
||||
(error) => expectGatewayError(error, 409, "approved_tool_managed_arguments_changed"),
|
||||
);
|
||||
const [inFlightRequest] = await db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
const [inFlightInvocation] = await db
|
||||
.select()
|
||||
.from(toolInvocations)
|
||||
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
||||
expect(inFlightRequest.status).toBe("executing");
|
||||
expect(inFlightInvocation).toMatchObject({
|
||||
status: "executing",
|
||||
approvalState: "approved",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
|
||||
const completedAt = new Date();
|
||||
const winnerSummary = summarizeToolValue({ winner: "concurrent execution" });
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "executed", resolvedAt: completedAt, updatedAt: completedAt })
|
||||
.where(eq(toolActionRequests.id, actionRequest.id));
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
status: "completed",
|
||||
resultSummary: winnerSummary,
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
})
|
||||
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
||||
const [winnerInvocation] = await db
|
||||
.select()
|
||||
.from(toolInvocations)
|
||||
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
||||
expect(winnerInvocation).toMatchObject({
|
||||
status: "completed",
|
||||
approvalState: "approved",
|
||||
resultSummary: winnerSummary,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(fake.requests).toHaveLength(0);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces policy, approvals, retries, rate limits, and company boundaries for connected remote MCP calls", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
|
@ -2664,19 +2971,41 @@ rl.on("line", (line) => {
|
|||
const otherCompany = await createCompany(db);
|
||||
const otherAgent = await createAgent(db, otherCompany.id);
|
||||
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
||||
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: fakeRequest.body?.id,
|
||||
result: {
|
||||
content: [{ type: "text", text: "connected ok" }],
|
||||
structuredContent: {
|
||||
receivedArguments: (fakeRequest.body?.params as Record<string, unknown> | undefined)?.arguments,
|
||||
leakedToken: "sk-connected-mcp-secret-123456",
|
||||
let pauseApprovedExecution = false;
|
||||
let approvedExecutionReached!: () => void;
|
||||
const approvedExecutionStarted = new Promise<void>((resolve) => {
|
||||
approvedExecutionReached = resolve;
|
||||
});
|
||||
let releaseApprovedExecution = () => {};
|
||||
const approvedExecutionRelease = new Promise<void>((resolve) => {
|
||||
releaseApprovedExecution = resolve;
|
||||
});
|
||||
const fake = await startFakeRemoteMcpServer(async (fakeRequest) => {
|
||||
const requestArguments = (fakeRequest.body?.params as Record<string, unknown> | undefined)?.arguments;
|
||||
if (
|
||||
pauseApprovedExecution
|
||||
&& requestArguments
|
||||
&& typeof requestArguments === "object"
|
||||
&& (requestArguments as Record<string, unknown>).key === "approved"
|
||||
) {
|
||||
pauseApprovedExecution = false;
|
||||
approvedExecutionReached();
|
||||
await approvedExecutionRelease;
|
||||
}
|
||||
return {
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: fakeRequest.body?.id,
|
||||
result: {
|
||||
content: [{ type: "text", text: "connected ok" }],
|
||||
structuredContent: {
|
||||
receivedArguments: requestArguments,
|
||||
leakedToken: "sk-connected-mcp-secret-123456",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const denyTool = await createRemoteMcpTool(db, company.id, {
|
||||
|
|
@ -2720,9 +3049,14 @@ rl.on("line", (line) => {
|
|||
expect(JSON.stringify(deniedInvocation)).not.toContain("sk-denied-secret-123456");
|
||||
|
||||
const approvalTool = await createRemoteMcpTool(db, company.id, {
|
||||
applicationKey: "approval-app",
|
||||
applicationKey: "shopify",
|
||||
toolName: "kv_set",
|
||||
url: fake.url,
|
||||
connectionConfig: {
|
||||
sourceTemplateKey: "shopify",
|
||||
connectionMethodKey: "ucp-commerce",
|
||||
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
||||
},
|
||||
});
|
||||
await allowToolsForAgent(db, company.id, agent.id, [
|
||||
expectedConnectedToolName({
|
||||
|
|
@ -2760,6 +3094,9 @@ rl.on("line", (line) => {
|
|||
issueId: issue.id,
|
||||
status: "pending",
|
||||
canonicalArgumentsHash: expect.any(String),
|
||||
canonicalArgumentsSummary: {
|
||||
summary: expect.stringContaining("valid-with-capabilities.json"),
|
||||
},
|
||||
});
|
||||
const [approvalInteraction] = await db
|
||||
.select()
|
||||
|
|
@ -2802,7 +3139,7 @@ rl.on("line", (line) => {
|
|||
policyDecision: "require_approval",
|
||||
connectionId: approvalTool.connection.id,
|
||||
providerType: "mcp_remote_http",
|
||||
applicationKey: "approval-app",
|
||||
applicationKey: "shopify",
|
||||
upstreamToolName: "kv_set",
|
||||
});
|
||||
|
||||
|
|
@ -2816,12 +3153,28 @@ rl.on("line", (line) => {
|
|||
})
|
||||
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
pauseApprovedExecution = true;
|
||||
const approvedExecution = gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: approvalToolName,
|
||||
parameters: { key: "approved", value: "tampered" },
|
||||
approvedActionRequestId: approvalRequest.id,
|
||||
})).resolves.toMatchObject({
|
||||
});
|
||||
await approvedExecutionStarted;
|
||||
const [executingApproval] = await db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.id, approvalRequest.id));
|
||||
expect(executingApproval.status).toBe("executing");
|
||||
|
||||
const concurrentRetry = gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: approvalToolName,
|
||||
parameters: { key: "approved", value: "original" },
|
||||
});
|
||||
releaseApprovedExecution();
|
||||
|
||||
await expect(approvedExecution).resolves.toMatchObject({
|
||||
status: "completed",
|
||||
tool: approvalToolName,
|
||||
result: {
|
||||
|
|
@ -2832,10 +3185,22 @@ rl.on("line", (line) => {
|
|||
},
|
||||
},
|
||||
});
|
||||
await expect(concurrentRetry).resolves.toMatchObject({
|
||||
status: "replayed",
|
||||
result: expect.anything(),
|
||||
});
|
||||
expect(fake.requests.at(-1)!.body).toMatchObject({
|
||||
params: {
|
||||
name: "kv_set",
|
||||
arguments: { key: "approved", value: "original" },
|
||||
arguments: {
|
||||
key: "approved",
|
||||
value: "original",
|
||||
meta: {
|
||||
"ucp-agent": {
|
||||
profile: "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const [executedApproval] = await db
|
||||
|
|
@ -3019,9 +3384,10 @@ rl.on("line", (line) => {
|
|||
expect(persisted).not.toContain("sk-connected-mcp-secret-123456");
|
||||
expect(persisted).not.toContain("sk-denied-secret-123456");
|
||||
expect(persisted).toContain("mcp_remote_http");
|
||||
expect(persisted).toContain("approval-app");
|
||||
expect(persisted).toContain("shopify");
|
||||
expect(persisted).toContain("kv_set");
|
||||
} finally {
|
||||
releaseApprovedExecution();
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1996,6 +1996,18 @@ function readToolActionContinuationContext(interaction: {
|
|||
};
|
||||
}
|
||||
|
||||
if (executionStatus === "expired") {
|
||||
const expirationMessage = error ? `: ${error}` : "";
|
||||
return {
|
||||
toolName,
|
||||
actionRequestId,
|
||||
decision: "accepted",
|
||||
executionStatus,
|
||||
...(error ? { error } : {}),
|
||||
instructions: `the approved ${toolName} action expired before execution${expirationMessage}; if the task still requires it, call the tool again to request a fresh approval.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toolName,
|
||||
actionRequestId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { extendApprovedExecutionWaitDeadline } from "./approved-execution-wait.js";
|
||||
|
||||
describe("extendApprovedExecutionWaitDeadline", () => {
|
||||
it("gives provider execution a full wait budget after approval preparation", () => {
|
||||
const preparationDeadlineMs = 65_000;
|
||||
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: preparationDeadlineMs,
|
||||
invocationStatus: "executing",
|
||||
invocationStartedAt: new Date(60_000),
|
||||
preparationStartedAt: new Date(0),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(125_000);
|
||||
});
|
||||
|
||||
it("gives asynchronous approval preparation its own bounded wait budget", () => {
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: 65_000,
|
||||
invocationStatus: "awaiting_approval",
|
||||
invocationStartedAt: new Date(60_000),
|
||||
preparationStartedAt: new Date(10_000),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(130_000);
|
||||
});
|
||||
|
||||
it("still gives the provider its full window after long preparation", () => {
|
||||
const preparationDeadlineMs = extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: 65_000,
|
||||
invocationStatus: "authorized",
|
||||
invocationStartedAt: null,
|
||||
preparationStartedAt: new Date(10_000),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
});
|
||||
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: preparationDeadlineMs,
|
||||
invocationStatus: "executing",
|
||||
invocationStartedAt: new Date(125_000),
|
||||
preparationStartedAt: new Date(10_000),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(190_000);
|
||||
});
|
||||
|
||||
it("never shortens an existing waiter deadline", () => {
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: 100_000,
|
||||
invocationStatus: "succeeded",
|
||||
invocationStartedAt: new Date(10_000),
|
||||
preparationStartedAt: new Date(0),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(100_000);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { ToolInvocationStatus } from "@paperclipai/shared";
|
||||
|
||||
const PREPARATION_STATUSES = new Set<ToolInvocationStatus>([
|
||||
"pending",
|
||||
"authorized",
|
||||
"awaiting_approval",
|
||||
]);
|
||||
|
||||
export function extendApprovedExecutionWaitDeadline(input: {
|
||||
currentDeadlineMs: number;
|
||||
invocationStatus: ToolInvocationStatus;
|
||||
invocationStartedAt: Date | null;
|
||||
preparationStartedAt: Date | null;
|
||||
preparationWaitMs: number;
|
||||
executionWaitMs: number;
|
||||
}): number {
|
||||
if (PREPARATION_STATUSES.has(input.invocationStatus)) {
|
||||
return input.preparationStartedAt
|
||||
? Math.max(
|
||||
input.currentDeadlineMs,
|
||||
input.preparationStartedAt.getTime() + input.preparationWaitMs,
|
||||
)
|
||||
: input.currentDeadlineMs;
|
||||
}
|
||||
if (!input.invocationStartedAt) {
|
||||
return input.currentDeadlineMs;
|
||||
}
|
||||
return Math.max(
|
||||
input.currentDeadlineMs,
|
||||
input.invocationStartedAt.getTime() + input.executionWaitMs,
|
||||
);
|
||||
}
|
||||
|
|
@ -927,6 +927,88 @@ export function projectedConnectionHeaders(connection: typeof toolConnections.$i
|
|||
return normalizeConnectionMethodConfig(method, asRecord(connection.config.methodConfig)).headers ?? {};
|
||||
}
|
||||
|
||||
function mergeManagedToolArguments(
|
||||
supplied: Record<string, unknown>,
|
||||
managed: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...supplied };
|
||||
for (const [key, value] of Object.entries(managed)) {
|
||||
const suppliedValue = merged[key];
|
||||
merged[key] = asRecord(value) === value && asRecord(suppliedValue) === suppliedValue
|
||||
? mergeManagedToolArguments(suppliedValue as Record<string, unknown>, value as Record<string, unknown>)
|
||||
: value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function projectConnectionMethodToolArguments(
|
||||
method: ConnectionMethodDef,
|
||||
parameters: unknown,
|
||||
): Record<string, unknown> {
|
||||
const supplied = asRecord(parameters);
|
||||
const managed = method.defaults?.toolArgumentDefaults;
|
||||
return managed ? mergeManagedToolArguments(supplied, managed) : supplied;
|
||||
}
|
||||
|
||||
function stripManagedToolArgumentSchema(
|
||||
schema: Record<string, unknown>,
|
||||
managed: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const properties = asRecord(schema.properties);
|
||||
if (Object.keys(properties).length === 0) return schema;
|
||||
const nextProperties = { ...properties };
|
||||
for (const [key, managedValue] of Object.entries(managed)) {
|
||||
const propertySchema = asRecord(nextProperties[key]);
|
||||
const managedRecord = asRecord(managedValue);
|
||||
if (Object.keys(propertySchema).length === 0 || Object.keys(managedRecord).length === 0) {
|
||||
delete nextProperties[key];
|
||||
continue;
|
||||
}
|
||||
const projectedProperty = stripManagedToolArgumentSchema(propertySchema, managedRecord);
|
||||
if (Object.keys(asRecord(projectedProperty.properties)).length === 0) delete nextProperties[key];
|
||||
else nextProperties[key] = projectedProperty;
|
||||
}
|
||||
const nextSchema: Record<string, unknown> = { ...schema, properties: nextProperties };
|
||||
if (Array.isArray(schema.required)) {
|
||||
const required = schema.required.filter((key): key is string => typeof key === "string" && key in nextProperties);
|
||||
if (required.length > 0) nextSchema.required = required;
|
||||
else delete nextSchema.required;
|
||||
}
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
export function projectConnectionMethodToolInputSchema(
|
||||
method: ConnectionMethodDef,
|
||||
inputSchema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const managed = method.defaults?.toolArgumentDefaults;
|
||||
return managed ? stripManagedToolArgumentSchema(inputSchema, managed) : inputSchema;
|
||||
}
|
||||
|
||||
export function projectedConnectionToolArguments(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
parameters: unknown,
|
||||
): Record<string, unknown> {
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
: null;
|
||||
const app = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
if (!app) return asRecord(parameters);
|
||||
return projectConnectionMethodToolArguments(connectionMethodForConnection(app, connection), parameters);
|
||||
}
|
||||
|
||||
export function projectedConnectionToolInputSchema(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
inputSchema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
: null;
|
||||
const app = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
if (!app) return inputSchema;
|
||||
return projectConnectionMethodToolInputSchema(connectionMethodForConnection(app, connection), inputSchema);
|
||||
}
|
||||
|
||||
function googleSheetsAllowedSpreadsheetIds(configValues: Record<string, unknown> | undefined): string[] {
|
||||
const raw = configValues?.allowedSpreadsheetIds;
|
||||
const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : [];
|
||||
|
|
@ -1233,7 +1315,11 @@ function toCatalogEntryForConnection(
|
|||
row: typeof toolCatalogEntries.$inferSelect,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
): ToolCatalogEntry {
|
||||
const catalogEntry = toCatalogEntry(row);
|
||||
const rawCatalogEntry = toCatalogEntry(row);
|
||||
const catalogEntry = {
|
||||
...rawCatalogEntry,
|
||||
inputSchema: projectedConnectionToolInputSchema(connection, rawCatalogEntry.inputSchema ?? {}),
|
||||
};
|
||||
if (
|
||||
connection.transport === "local_stdio"
|
||||
&& asRecord(connection.config).templateId === GOOGLE_SHEETS_TEMPLATE_ID
|
||||
|
|
@ -1737,6 +1823,12 @@ const NOTION_WRITE_TOOLS = new Set([
|
|||
"notion-update-view",
|
||||
]);
|
||||
|
||||
const SHOPIFY_DESTRUCTIVE_TOOLS = new Set([
|
||||
"cancel-cart",
|
||||
"cancel-checkout",
|
||||
"complete-checkout",
|
||||
]);
|
||||
|
||||
function normalizedProviderToolName(toolName: string): string {
|
||||
return toolName
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
|
|
@ -1749,6 +1841,7 @@ export function classifyRisk(tool: McpToolDescriptor, sourceTemplateKey?: string
|
|||
if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive";
|
||||
const normalizedToolName = normalizedProviderToolName(tool.name);
|
||||
if (sourceTemplateKey === "posthog" && normalizedToolName === "exec") return "destructive";
|
||||
if (sourceTemplateKey === "shopify" && SHOPIFY_DESTRUCTIVE_TOOLS.has(normalizedToolName)) return "destructive";
|
||||
// Notion's hosted MCP catalog contains mutations whose names do not use one
|
||||
// of the generic create/update/delete verbs (move, duplicate, and convert).
|
||||
// Keep all reviewed tools explicit so provider changes are visible in code,
|
||||
|
|
|
|||
|
|
@ -68,7 +68,11 @@ import {
|
|||
mcpHttpRequestHeaders,
|
||||
parseMcpHttpResponseBody,
|
||||
} from "./mcp-http.js";
|
||||
import { projectedConnectionHeaders } from "./tool-access.js";
|
||||
import {
|
||||
projectedConnectionHeaders,
|
||||
projectedConnectionToolArguments,
|
||||
projectedConnectionToolInputSchema,
|
||||
} from "./tool-access.js";
|
||||
import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
|
||||
import { guardedRemoteHttpFetch, type GuardedRemoteHttpFetchOptions } from "./remote-http-fetch.js";
|
||||
import {
|
||||
|
|
@ -109,6 +113,7 @@ import {
|
|||
validateToolContent,
|
||||
verifyToolArgumentsSignature,
|
||||
} from "./tool-content-guards.js";
|
||||
import { extendApprovedExecutionWaitDeadline } from "./approved-execution-wait.js";
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
|
|
@ -139,6 +144,16 @@ export function isConnectionGrantAudienceAllowed(
|
|||
// `tool_timeout` even though the approval succeeded. Give approved executions
|
||||
// the full permitted headroom instead.
|
||||
const APPROVED_EXECUTION_TIMEOUT_MS = 60_000;
|
||||
const ACTION_REQUEST_EXECUTION_POLL_MS = 25;
|
||||
// Approval execution performs live target, signature, managed-argument, and
|
||||
// issue-state checks before provider dispatch. Give that preparation a
|
||||
// separate bounded window so it cannot consume the provider's execution
|
||||
// budget for concurrent consumers.
|
||||
const ACTION_REQUEST_PREPARATION_WAIT_MS = 2 * 60 * 1000;
|
||||
// Concurrent consumers must wait at least as long as the provider execution
|
||||
// they are joining. The extra grace lets the owner persist the terminal request
|
||||
// state after the provider timeout/result settles.
|
||||
const ACTION_REQUEST_EXECUTION_WAIT_MS = APPROVED_EXECUTION_TIMEOUT_MS + 5_000;
|
||||
// The gateway creates an ask-first request in two steps: it inserts the row
|
||||
// with a null signature, then it signs the row and sets the expiry. A concurrent
|
||||
// matching call can observe the row in this window. A null signature alone does
|
||||
|
|
@ -833,6 +848,10 @@ export function createToolGatewayService(
|
|||
}) => Promise<typeof connectionGrants.$inferSelect>;
|
||||
/** Test seam for resolving Vercel Connect credentials. */
|
||||
vercelConnectClient?: VercelConnectClient | null;
|
||||
/** Test seam for reproducing the managed-argument drift expiry race. */
|
||||
beforeManagedArgumentDriftExpiry?: () => Promise<void>;
|
||||
/** Test seam for pausing a legacy approved request before its execution claim. */
|
||||
beforeLegacyApprovedActionClaim?: () => Promise<void>;
|
||||
mcpGatewayProtocolLimits?: Partial<{
|
||||
authFailures: Partial<McpGatewayRateLimitConfig>;
|
||||
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
|
||||
|
|
@ -1001,7 +1020,7 @@ export function createToolGatewayService(
|
|||
? `${baseName}-${shortStableId(catalogEntry.id)}`
|
||||
: baseName;
|
||||
const applicationKey = application.applicationKey ?? null;
|
||||
const inputSchema = catalogEntry.inputSchema ?? {};
|
||||
const inputSchema = projectedConnectionToolInputSchema(connection, catalogEntry.inputSchema ?? {});
|
||||
const outputSchema = catalogEntry.outputSchema ?? null;
|
||||
const annotations = catalogEntry.annotations ?? {};
|
||||
const risk = riskFromCatalogEntry(catalogEntry);
|
||||
|
|
@ -3253,6 +3272,25 @@ export function createToolGatewayService(
|
|||
return { entry, connection };
|
||||
}
|
||||
|
||||
async function governedToolArguments(
|
||||
session: ToolGatewaySession,
|
||||
tool: ToolGatewayDescriptor,
|
||||
parameters: unknown,
|
||||
): Promise<unknown> {
|
||||
if (tool.providerType !== "mcp_remote_http") return parameters;
|
||||
const { connection } = await resolveConnectedRemoteTool(session, tool);
|
||||
return projectedConnectionToolArguments(connection, parameters);
|
||||
}
|
||||
|
||||
async function approvedManagedArgumentsRemainCurrent(
|
||||
session: ToolGatewaySession,
|
||||
tool: ToolGatewayDescriptor,
|
||||
reviewedParameters: unknown,
|
||||
): Promise<boolean> {
|
||||
const currentParameters = await governedToolArguments(session, tool, reviewedParameters);
|
||||
return stableSerialize(currentParameters) === stableSerialize(reviewedParameters);
|
||||
}
|
||||
|
||||
async function resolveConnectedLocalStdioTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
|
||||
if (tool.providerType !== "mcp_local_stdio" || !tool.connectionId || !tool.catalogEntryId) {
|
||||
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
|
||||
|
|
@ -3871,7 +3909,7 @@ export function createToolGatewayService(
|
|||
method: "tools/call",
|
||||
params: {
|
||||
name: entry.toolName,
|
||||
arguments: parameters ?? {},
|
||||
arguments: parameters,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
@ -4895,17 +4933,39 @@ export function createToolGatewayService(
|
|||
}
|
||||
|
||||
async function waitForActionRequestExecution(actionRequestId: string) {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
let deadline = Date.now() + ACTION_REQUEST_EXECUTION_WAIT_MS;
|
||||
while (true) {
|
||||
const [match] = await db
|
||||
.select({
|
||||
actionRequest: toolActionRequests,
|
||||
invocationStatus: toolInvocations.status,
|
||||
invocationStartedAt: toolInvocations.startedAt,
|
||||
})
|
||||
.from(toolActionRequests)
|
||||
.innerJoin(toolInvocations, eq(toolInvocations.id, toolActionRequests.invocationId))
|
||||
.where(eq(toolActionRequests.id, actionRequestId))
|
||||
.limit(1);
|
||||
const row = match?.actionRequest;
|
||||
if (!row || row.status !== "executing") return row ?? null;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
deadline = extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: deadline,
|
||||
invocationStatus: match.invocationStatus,
|
||||
invocationStartedAt: match.invocationStartedAt,
|
||||
preparationStartedAt: row.updatedAt,
|
||||
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
|
||||
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
|
||||
});
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) break;
|
||||
await new Promise((resolve) => setTimeout(
|
||||
resolve,
|
||||
Math.min(ACTION_REQUEST_EXECUTION_POLL_MS, remainingMs),
|
||||
));
|
||||
}
|
||||
throw new ToolGatewayHttpError(409, "Approved tool action is still executing", "action_execution_in_progress", {
|
||||
actionRequestId,
|
||||
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
|
||||
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4936,6 +4996,8 @@ export function createToolGatewayService(
|
|||
async function markApprovedActionFailed(input: {
|
||||
actionRequestId: string;
|
||||
invocationId: string;
|
||||
claimUpdatedAt: Date;
|
||||
expectedInvocationStatus: "awaiting_approval" | "executing";
|
||||
error: unknown;
|
||||
}) {
|
||||
const reasonCode = input.error instanceof ToolGatewayHttpError
|
||||
|
|
@ -4943,25 +5005,109 @@ export function createToolGatewayService(
|
|||
: "tool_execution_failed";
|
||||
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
||||
const now = new Date();
|
||||
await db.update(toolInvocations).set({
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(eq(toolInvocations.id, input.invocationId));
|
||||
await db.update(toolActionRequests).set({
|
||||
status: "failed",
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(eq(toolActionRequests.id, input.actionRequestId));
|
||||
const settled = await db.transaction(async (tx) => {
|
||||
// Lock in the same invocation -> request order used by the normal
|
||||
// execution settlement path. The claim timestamp is the ownership token:
|
||||
// a consumer that merely read an approved row cannot settle another
|
||||
// consumer's claim, and a pre-dispatch failure cannot overwrite a call
|
||||
// that has already entered provider execution or completed successfully.
|
||||
const [invocation] = await tx
|
||||
.select({ status: toolInvocations.status })
|
||||
.from(toolInvocations)
|
||||
.where(eq(toolInvocations.id, input.invocationId))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
if (invocation?.status !== input.expectedInvocationStatus) return false;
|
||||
|
||||
const [actionRequest] = await tx
|
||||
.select({ status: toolActionRequests.status, updatedAt: toolActionRequests.updatedAt })
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.id, input.actionRequestId))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
if (
|
||||
actionRequest?.status !== "executing"
|
||||
|| actionRequest.updatedAt.getTime() !== input.claimUpdatedAt.getTime()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await tx.update(toolInvocations).set({
|
||||
status: "failed",
|
||||
idempotencyKey: null,
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(toolInvocations.id, input.invocationId),
|
||||
eq(toolInvocations.status, input.expectedInvocationStatus),
|
||||
));
|
||||
await tx.update(toolActionRequests).set({
|
||||
status: "failed",
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(toolActionRequests.id, input.actionRequestId),
|
||||
eq(toolActionRequests.status, "executing"),
|
||||
eq(toolActionRequests.updatedAt, input.claimUpdatedAt),
|
||||
));
|
||||
return true;
|
||||
});
|
||||
if (!settled) return { reasonCode, message, settled: false };
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: input.actionRequestId,
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
});
|
||||
return { reasonCode, message };
|
||||
return { reasonCode, message, settled: true };
|
||||
}
|
||||
|
||||
async function expireApprovedActionForManagedArgumentDrift(input: {
|
||||
actionRequestId: string;
|
||||
invocationId: string;
|
||||
toolName: string;
|
||||
ownsExecutingClaim?: boolean;
|
||||
}) {
|
||||
const error = new ToolGatewayHttpError(
|
||||
409,
|
||||
"Approved tool action managed arguments changed after review; request a new approval",
|
||||
"approved_tool_managed_arguments_changed",
|
||||
{ actionRequestId: input.actionRequestId, invocationId: input.invocationId, tool: input.toolName },
|
||||
);
|
||||
const now = new Date();
|
||||
await options.beforeManagedArgumentDriftExpiry?.();
|
||||
const [expired] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "expired", resolvedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, input.actionRequestId),
|
||||
input.ownsExecutingClaim
|
||||
? inArray(toolActionRequests.status, ["approved", "executing"])
|
||||
: eq(toolActionRequests.status, "approved"),
|
||||
))
|
||||
.returning({ id: toolActionRequests.id });
|
||||
if (!expired) return error;
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
status: "failed",
|
||||
approvalState: "expired",
|
||||
idempotencyKey: null,
|
||||
errorCode: error.reasonCode,
|
||||
errorMessage: error.message,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolInvocations.id, input.invocationId));
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: expired.id,
|
||||
status: "expired",
|
||||
errorCode: error.reasonCode,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
// Guard for approved-action execution: the issue must still be open. Expires
|
||||
|
|
@ -5046,15 +5192,29 @@ export function createToolGatewayService(
|
|||
});
|
||||
if (!signedPayload) {
|
||||
const error = new ToolGatewayHttpError(409, "Approved tool action arguments signature is invalid", "signed_arguments_invalid");
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (signedPayload.executionOnApprove !== true) {
|
||||
throw new ToolGatewayHttpError(
|
||||
const error = new ToolGatewayHttpError(
|
||||
409,
|
||||
"This approval predates execute-on-approve and must remain inert",
|
||||
"legacy_approved_action_inert",
|
||||
);
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const session: ToolGatewaySession = {
|
||||
|
|
@ -5079,12 +5239,24 @@ export function createToolGatewayService(
|
|||
tool = await findToolForSession(session, invocation.toolName);
|
||||
liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(session, tool);
|
||||
} catch (error) {
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (!approvalSnapshotsMatch(signedPayload.approvalSnapshot, liveApprovalSnapshot)) {
|
||||
const error = new ToolGatewayHttpError(409, "Approved tool action target changed after review", "approved_tool_target_changed");
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const parameters = signedPayload.arguments;
|
||||
|
|
@ -5102,9 +5274,36 @@ export function createToolGatewayService(
|
|||
})
|
||||
) {
|
||||
const error = new ToolGatewayHttpError(409, "Approved tool action arguments do not match reviewed hash", "signed_arguments_mismatch");
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
let managedArgumentsRemainCurrent: boolean;
|
||||
try {
|
||||
managedArgumentsRemainCurrent = await approvedManagedArgumentsRemainCurrent(session, tool, parameters);
|
||||
} catch (error) {
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (!managedArgumentsRemainCurrent) {
|
||||
throw await expireApprovedActionForManagedArgumentDrift({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
toolName: invocation.toolName,
|
||||
ownsExecutingClaim: true,
|
||||
});
|
||||
}
|
||||
|
||||
const argumentsSummary = validateToolContent({
|
||||
value: parameters,
|
||||
|
|
@ -5179,6 +5378,8 @@ export function createToolGatewayService(
|
|||
const { reasonCode } = await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "executing",
|
||||
error,
|
||||
});
|
||||
await writeToolCallEvent({
|
||||
|
|
@ -5298,13 +5499,6 @@ export function createToolGatewayService(
|
|||
);
|
||||
}
|
||||
if (actionRequest.status === "approved" && actionRequest.decidedAt) {
|
||||
const signedPayload = readSignedToolArgumentsPayload({
|
||||
signedArguments: actionRequest.signedArguments,
|
||||
invocationId: invocation.id,
|
||||
toolName: invocation.toolName,
|
||||
signingSecret: options.toolActionSigningSecret,
|
||||
});
|
||||
if (signedPayload?.executionOnApprove !== true) return null;
|
||||
const result = await executeApprovedAgentInvocation({ actionRequest, invocation });
|
||||
return { matched: true as const, result, invocationId: invocation.id };
|
||||
}
|
||||
|
|
@ -5870,7 +6064,7 @@ export function createToolGatewayService(
|
|||
});
|
||||
}
|
||||
|
||||
const requestedParameters = input.parameters ?? {};
|
||||
const requestedParameters = await governedToolArguments(session, tool, input.parameters ?? {});
|
||||
const argumentValidation = validateToolContent({
|
||||
value: requestedParameters,
|
||||
direction: "arguments",
|
||||
|
|
@ -6356,6 +6550,15 @@ export function createToolGatewayService(
|
|||
requestedParameters = targetParameters;
|
||||
}
|
||||
|
||||
// Managed provider arguments are part of the governed call, not a
|
||||
// transport decoration. Project them before hashing, policy evaluation,
|
||||
// approval signing, previews, and audit summaries. Approved retries
|
||||
// re-project only for a compatibility comparison and dispatch the
|
||||
// already-reviewed signed payload unchanged.
|
||||
if (!input.approvedActionRequestId) {
|
||||
requestedParameters = await governedToolArguments(session, tool, requestedParameters);
|
||||
}
|
||||
|
||||
const argumentValidation = validateToolContent({
|
||||
value: requestedParameters,
|
||||
direction: "arguments",
|
||||
|
|
@ -6526,6 +6729,43 @@ export function createToolGatewayService(
|
|||
if (!signedPayload) {
|
||||
throw new ToolGatewayHttpError(409, "Approved tool action arguments signature is invalid", "signed_arguments_invalid");
|
||||
}
|
||||
if (signedPayload.executionOnApprove !== true) {
|
||||
const error = new ToolGatewayHttpError(
|
||||
409,
|
||||
"This approval predates execute-on-approve and must remain inert",
|
||||
"legacy_approved_action_inert",
|
||||
);
|
||||
await options.beforeLegacyApprovedActionClaim?.();
|
||||
const claimedAt = new Date();
|
||||
const [claimed] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({
|
||||
status: "executing",
|
||||
resolvedByAgentId: session.agentId,
|
||||
updatedAt: claimedAt,
|
||||
})
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, actionRequest.id),
|
||||
eq(toolActionRequests.status, "approved"),
|
||||
))
|
||||
.returning();
|
||||
if (!claimed) {
|
||||
throw new ToolGatewayHttpError(
|
||||
409,
|
||||
"Tool action request was already consumed",
|
||||
"action_already_consumed",
|
||||
);
|
||||
}
|
||||
await reflectToolActionInteractionLifecycle({ actionRequestId: claimed.id, status: "executing" });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: storedInvocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(session, tool);
|
||||
if (!approvalSnapshotsMatch(signedPayload.approvalSnapshot, liveApprovalSnapshot)) {
|
||||
throw new ToolGatewayHttpError(
|
||||
|
|
@ -6561,20 +6801,27 @@ export function createToolGatewayService(
|
|||
) {
|
||||
throw new ToolGatewayHttpError(409, "Approved tool action arguments do not match reviewed hash", "signed_arguments_mismatch");
|
||||
}
|
||||
const [consumed] = await db
|
||||
if (!await approvedManagedArgumentsRemainCurrent(session, tool, storedParameters)) {
|
||||
throw await expireApprovedActionForManagedArgumentDrift({
|
||||
actionRequestId: actionRequest.id,
|
||||
invocationId: storedInvocation.id,
|
||||
toolName: storedInvocation.toolName,
|
||||
});
|
||||
}
|
||||
const claimedAt = new Date();
|
||||
const [claimed] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({
|
||||
status: "executed",
|
||||
status: "executing",
|
||||
resolvedByAgentId: session.agentId,
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updatedAt: claimedAt,
|
||||
})
|
||||
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "approved")))
|
||||
.returning();
|
||||
if (!consumed) {
|
||||
if (!claimed) {
|
||||
throw new ToolGatewayHttpError(409, "Tool action request was already consumed", "action_already_consumed");
|
||||
}
|
||||
await reflectToolActionInteractionLifecycle({ actionRequestId: consumed.id, status: "executing" });
|
||||
await reflectToolActionInteractionLifecycle({ actionRequestId: claimed.id, status: "executing" });
|
||||
invocationId = storedInvocation.id as typeof invocationId;
|
||||
effectiveParameters = storedParameters;
|
||||
effectiveArgumentsSummary = storedArgumentValidation.summary;
|
||||
|
|
@ -6724,6 +6971,7 @@ export function createToolGatewayService(
|
|||
sensitiveMode: "redact",
|
||||
promptInjectionMode: "block",
|
||||
});
|
||||
const completedAt = new Date();
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
|
|
@ -6731,15 +6979,25 @@ export function createToolGatewayService(
|
|||
resultHash: resultValidation.summary.sha256 ?? null,
|
||||
resultSummary: resultValidation.summary,
|
||||
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
})
|
||||
.where(eq(toolInvocations.id, invocationId));
|
||||
if (input.approvedActionRequestId) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: input.approvedActionRequestId,
|
||||
status: "executed",
|
||||
});
|
||||
const [executedRequest] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "executed", resolvedAt: completedAt, updatedAt: completedAt })
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, input.approvedActionRequestId),
|
||||
eq(toolActionRequests.status, "executing"),
|
||||
))
|
||||
.returning({ id: toolActionRequests.id });
|
||||
if (executedRequest) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: executedRequest.id,
|
||||
status: "executed",
|
||||
});
|
||||
}
|
||||
}
|
||||
await writeToolCallEvent({
|
||||
invocationId,
|
||||
|
|
@ -6813,23 +7071,34 @@ export function createToolGatewayService(
|
|||
if (reasonCode === "elicitation_required") {
|
||||
throw normalizedError;
|
||||
}
|
||||
const completedAt = new Date();
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
status: status === 504 ? "timed_out" : status === 429 ? "rate_limited" : "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
})
|
||||
.where(eq(toolInvocations.id, invocationId));
|
||||
if (input.approvedActionRequestId) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: input.approvedActionRequestId,
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
});
|
||||
const [failedRequest] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "failed", resolvedAt: completedAt, updatedAt: completedAt })
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, input.approvedActionRequestId),
|
||||
eq(toolActionRequests.status, "executing"),
|
||||
))
|
||||
.returning({ id: toolActionRequests.id });
|
||||
if (failedRequest) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: failedRequest.id,
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
await writeToolCallEvent({
|
||||
invocationId,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github")!
|
|||
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!;
|
||||
const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!;
|
||||
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
|
||||
const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!;
|
||||
const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!;
|
||||
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
|
||||
const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!;
|
||||
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
|
||||
|
|
@ -495,6 +497,53 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("submits the Postman access mode selected on the setup screen", async () => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [POSTMAN] });
|
||||
mockParams.appKey = "postman";
|
||||
connectAppMock.mockResolvedValueOnce({
|
||||
connectionId: "conn-postman",
|
||||
application: { id: "app-postman", name: "Postman" },
|
||||
connection: { id: "conn-postman" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
auth: { kind: "oauth", startUrl: "https://oauth.pstmn.io/authorize?state=opaque" },
|
||||
});
|
||||
|
||||
await render();
|
||||
await passAccessStep();
|
||||
|
||||
const full = radioContaining("Full");
|
||||
const code = radioContaining("Code");
|
||||
expect(full).toBeTruthy();
|
||||
expect(code).toBeTruthy();
|
||||
expect(full?.getAttribute("aria-checked")).toBe("true");
|
||||
await act(async () => {
|
||||
code?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(code?.getAttribute("aria-checked")).toBe("true");
|
||||
await act(async () => {
|
||||
full?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(full?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("US · Browser sign-in")?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
galleryKey: "postman",
|
||||
connectionMethodKey: "mcp-oauth-full",
|
||||
}));
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||||
"https://oauth.pstmn.io/authorize?state=opaque",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a manual OAuth app from the same source deep link Browse uses", async () => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [ASANA] });
|
||||
mockParams.appKey = undefined;
|
||||
|
|
@ -542,6 +591,22 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(container.textContent).toContain("Your OAuth app");
|
||||
});
|
||||
|
||||
it("explains Shopify's public-storefront gate before collecting the store domain", async () => {
|
||||
mockParams.appKey = "shopify";
|
||||
listGalleryMock.mockResolvedValue({ apps: [SHOPIFY] });
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Launch the storefront before connecting");
|
||||
expect(container.textContent).toContain("Storefront visibility to Public");
|
||||
expect(container.textContent).toContain("private or password-protected storefront returns HTTP 401");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).find((link) =>
|
||||
link.textContent?.includes("Open Shopify Admin"),
|
||||
)?.href,
|
||||
).toBe("https://admin.shopify.com/");
|
||||
});
|
||||
|
||||
/**
|
||||
* Design §"Question 2": a member who may create a personal grant but cannot
|
||||
* configure a company-wide install sees **Any agent** disabled with the
|
||||
|
|
|
|||
Loading…
Reference in New Issue