mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(guidance): tell agents who acts and which page to link for person-only steps (#20206)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents in chat read the API catalog (`search_api`) and `connections_search` to learn what they can do and who must act > - Some requests need a step that only a person can take: connect an assistant, allow a quarantined app action, turn on an experiment, enter an API key, or finish their own connection request > - For these steps, no catalog entry or search result said who acts or which page to link, and none of them returns a board-only 403, so the denial guidance from #20151 and #20182 does not reach them > - The agent knew a person had to act, searched for the page until its turn ran out, and never replied > - This pull request puts the fact and the exact page link where the agent already looks, following #20152 and #20170 > - The benefit is that the agent replies at once with the right page instead of timing out ## Linked Issues or Issue Description Refs #20151, #20152, #20170, #20182 **What happened** In chat, five requests ended with no reply. The agent searched the API until the turn deadline: - "Connect my assistant so it can see our tasks." `GET /api/mcp/setup` is refused for agents. Its catalog entry had no description, so the refusal text "Its catalog description says who can use it" pointed at nothing. - "Use the app's new export feature." The new action was quarantined. No catalog entry said where a person reviews it. - "Let me chat with Morgan over iMessage." `connections_search` hides chat channels while the Chat connectors experiment is off, so the agent could not find iMessage or the setting that hides it. `POST /api/companies/{companyId}/chat-endpoints` also accepted a draft endpoint for the turned-off provider. - "Connect my account using an API key." The service was the person's own MCP server. `connections_search` returned no result and did not name the secure "Connect your own MCP server" page. - "I will finish Alex's connection request for them." No catalog entry said that only the addressee can resolve a connection request card. **Expected behavior** The agent says who must act, links the exact page, and replies in the same turn. It does not search further, does not ask for a key in chat, and does not create another request. **Steps to reproduce** 1. Ask a chat agent one of the five requests above, in the matching state (for example, Chat connectors off, or a quarantined catalog entry). 2. Watch the run events. The agent calls `search_api` and `call_api` until the turn deadline and posts no reply. ## What Changed - `server/src/routes/openapi.ts`: add catalog descriptions that say who acts, link the page, and say not to search further: - `GET /api/mcp/setup` and `POST /api/mcp/requests/{id}/consent` link [Assistant connections](/<prefix>/apps/assistant-connection) and say the consent screen offers only access within the person's own role. - `GET /api/tool-connections/{connectionId}/catalog`, `.../review-counts` and `GET /api/companies/{companyId}/tools/apps/attention` say a quarantined action waits for a person on `/<prefix>/apps/<connectionId>/permissions`. - The four `/api/connection-intents/{interactionId}/...` routes say only the addressee can resolve the card, and link the task that holds it. - `server/src/services/connection-intents.ts`: - While Chat connectors is off, a search that names a chat-only channel (for example iMessage) returns it as `unavailable`, with a reason that links `/<prefix>/company/settings/instance/experimental`. Channels that also offer tools, such as Slack, keep their current result. - A single `unavailable` result now always gets the "explain the reason, do not bypass" instruction. - When no listed service matches and the person gave the service's own server URL, the instruction links `/<prefix>/apps/byo`, the secure "Connect your own MCP server" page, and says never to ask for a key in chat. - `server/src/routes/chat-channels.ts`: `POST /api/companies/{companyId}/chat-endpoints` returns 403 with the Experimental settings page while the provider's experiment is off (Chat connectors, or GitHub review bots for GitHub). AgentMail is unchanged. The catalog entry says the same. The UI already hides the setup pages in this state. - `GET /api/issues/{id}/interactions` says only the addressee resolves a connection request card. - A hidden channel that an aggregator can serve (for example Discord) keeps its aggregator routes instead of the unavailable result. - Tests: `person-only-page-guidance.test.ts` checks the catalog text and that every named page is a route that `ui/src/App.tsx` mounts. `connection-intents-service.test.ts` covers the hidden channel (off and on) and the own-server page. ## Verification - `cd server && npx vitest run src/__tests__/person-only-page-guidance.test.ts src/__tests__/connection-intents-service.test.ts` passes. `chat-channels.integration.test.ts` passes with `-t` for every test that posts to the create route. - These related suites pass: `openapi-routes`, `connection-aggregator-fallback`, `generic-mcp-connection`, `assistant-connections-guidance`, `company-model-service-settings-guidance`, `board-access-denial`, `email-channels.integration`, and the eight suites that import `runner-api-catalog`. - `npx tsc --noEmit -p server` shows no errors in the changed files. - End to end: the full-stack chat evals for the five requests ran on a branch with this change. The run results are in the comments below. ## Risks - Low risk. The change is text in the API catalog and in `connections_search` results, plus one result for a channel that was hidden before. - A hidden channel now shows in search as `unavailable` with no methods. It cannot be set up from the result, so the gate stays in place. - Chat endpoint creation is now refused while the experiment is off. Callers that created endpoints with the setting off now get 403. Three integration tests that relied on that now turn the setting on first, and a new test covers the refusal. - The single-`unavailable` instruction now also applies in suggestion mode. Before, that mode told the agent to "respect unavailable states" with no reason. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude Code with tool use (shell, file edits, test runs). ## 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 (e.g. `docs/...`, `fix/...`) 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c18cf61da
commit
fc5c79a327
6 files changed
+208
-7
No files matched your search
@@ -9607,7 +9607,27 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("refuses to create a chat endpoint while its provider experiment is off", async () => {
|
||||
const settings = instanceSettingsService(db);
|
||||
const previous = await settings.getExperimental();
|
||||
try {
|
||||
await settings.updateExperimental({ enableChatConnectors: false, enableGitHubReviewBots: false });
|
||||
const fixture = await seedCompany();
|
||||
const { service } = createService();
|
||||
const app = routesApp(db, fixture.companyId, service);
|
||||
for (const provider of ["imessage-photon", "slack", "github"]) {
|
||||
const response = await request(app).post(`/api/companies/${fixture.companyId}/chat-endpoints`)
|
||||
.send({ provider, assignedAgentId: fixture.assignedAgentId }).expect(403);
|
||||
expect(response.body.error).toContain("/<prefix>/company/settings/instance/experimental");
|
||||
}
|
||||
expect(await service.list(fixture.companyId)).toEqual([]);
|
||||
} finally {
|
||||
await settings.updateExperimental({ enableChatConnectors: previous.enableChatConnectors, enableGitHubReviewBots: previous.enableGitHubReviewBots });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a channel-purpose connection and rejects attempts to change its assigned agent", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||
const fixture = await seedCompany();
|
||||
const { service } = createService();
|
||||
const app = routesApp(db, fixture.companyId, service);
|
||||
@@ -9662,6 +9682,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const signingSecret = "signing-secret-canary";
|
||||
const clientSecret = "client-secret-canary";
|
||||
it("saves advanced app details with the initial draft and rejects them for other providers", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||
const company = await seedCompany();
|
||||
const { service } = createService();
|
||||
const app = routesApp(db, company.companyId, service);
|
||||
@@ -11808,6 +11829,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
});
|
||||
|
||||
it("rejects chat endpoints for non-invokable agents", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||
const fixture = await seedCompany();
|
||||
const { service } = createService();
|
||||
const app = routesApp(db, fixture.companyId, service);
|
||||
|
||||
@@ -226,6 +226,41 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names the instance setting and its page when a requested chat channel is turned off", async () => {
|
||||
const settings = instanceSettingsService(db);
|
||||
const previous = await settings.getExperimental();
|
||||
const service = connectionIntentService(db);
|
||||
try {
|
||||
await settings.updateExperimental({ enableChatConnectors: false });
|
||||
expect((await service.search(claims, "iMessage chat messaging with a contact")).results.find(item => item.service === "imessage-photon")?.state).toBe("unavailable");
|
||||
const off = await service.search(claims, "iMessage");
|
||||
const imessage = off.results.find(item => item.service === "imessage-photon");
|
||||
expect(imessage).toMatchObject({ state: "unavailable", methods: [] });
|
||||
expect(imessage?.reason).toMatch(/\[Experimental settings\]\(\/[A-Z0-9]+\/company\/settings\/instance\/experimental\)/);
|
||||
expect(off.instruction).toContain("Explain the reason");
|
||||
expect((await service.search(claims, "slack")).results.some(item => item.state === "unavailable" && item.methods.length === 0)).toBe(false);
|
||||
expect((await service.search(claims, "discord")).results.some(item => item.service === "discord" && item.state === "unavailable")).toBe(false);
|
||||
const partial = await service.search(claims, "imess");
|
||||
expect(partial.results.map(item => item.service)).toEqual(["imessage-photon"]);
|
||||
expect(partial.instruction).toContain("Explain the reason");
|
||||
await settings.updateExperimental({ enableChatConnectors: true });
|
||||
const on = (await service.search(claims, "iMessage")).results.find(item => item.service === "imessage-photon");
|
||||
expect(on?.state).not.toBe("unavailable");
|
||||
expect(on?.methods.some(method => method.purpose === "channel")).toBe(true);
|
||||
} finally {
|
||||
await settings.updateExperimental({ enableChatConnectors: previous.enableChatConnectors });
|
||||
}
|
||||
});
|
||||
|
||||
it("points a service the catalog does not list at the secure own-server setup page", async () => {
|
||||
const ranked = await connectionIntentService(db).search(claims, "Synthetic notes service connect my account with an API key");
|
||||
expect(ranked.results.length).toBeGreaterThan(1);
|
||||
expect(ranked.instruction).toMatch(/^These are ranked connection matches[\s\S]*\[Connect your own MCP server\]\(\/[A-Z0-9]+\/apps\/byo\)/);
|
||||
const result = await connectionIntentService(db).search(claims, "Synthetic notes service");
|
||||
expect(result.instruction).toMatch(/\[Connect your own MCP server\]\(\/[A-Z0-9]+\/apps\/byo\)/);
|
||||
expect(result.instruction).toContain("Do not create the connection yourself, ask for the key in chat");
|
||||
});
|
||||
|
||||
it("creates one addressed request, resolves after install, and then reports ready for the responsible user", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
const first = await service.request(claims, "notion");
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
import { runnerApiCatalog, searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
it.each([
|
||||
["connect my assistant mcp setup", "GET /api/mcp/setup"],
|
||||
["assistant connection consent approve access", "POST /api/mcp/requests/{id}/consent"],
|
||||
])("search %j says only the person connects an assistant, and links the page", (query, operationId) => {
|
||||
const operation = searchRunnerApi({ query, limit: 10 }).results.find(row => row.operationId === operationId);
|
||||
expect(operation, query).toBeDefined();
|
||||
expect(operation!.description).toContain("[Assistant connections](/<prefix>/apps/assistant-connection)");
|
||||
expect(operation!.description).toContain("a read-only member can grant read access but not writes");
|
||||
expect(operation!.description).toContain("Do not search for another route");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tool connection catalog quarantined", "GET /api/tool-connections/{connectionId}/catalog"],
|
||||
["quarantined catalog entry counts review", "GET /api/tool-connections/{connectionId}/review-counts"],
|
||||
["tool apps needing attention", "GET /api/companies/{companyId}/tools/apps/attention"],
|
||||
])("search %j says where a person reviews a quarantined action", (query, operationId) => {
|
||||
const operation = searchRunnerApi({ query, limit: 10 }).results.find(row => row.operationId === operationId);
|
||||
expect(operation, query).toBeDefined();
|
||||
expect(operation!.description).toContain("[Permissions](/<prefix>/apps/<connectionId>/permissions)");
|
||||
expect(operation!.description).toContain("No agent can call a quarantined action");
|
||||
});
|
||||
|
||||
it("says chat endpoint creation needs the experiment, and links where it is turned on", () => {
|
||||
const create = searchRunnerApi({ query: "create chat endpoint imessage", limit: 10 }).results.find(row => row.operationId === "POST /api/companies/{companyId}/chat-endpoints");
|
||||
expect(create?.description).toContain("[Experimental settings](/<prefix>/company/settings/instance/experimental)");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"complete connection request for someone else",
|
||||
"connection intent resolve decline",
|
||||
])("search %j says only the addressee can resolve a connection request card", (query) => {
|
||||
const found = searchRunnerApi({ query, limit: 10 }).results.filter(row => row.path.startsWith("/api/connection-intents/"));
|
||||
expect(found.length, query).toBeGreaterThan(0);
|
||||
for (const operation of found) {
|
||||
expect(operation.description).toContain("Only that person, signed in to their own browser");
|
||||
expect(operation.description).toContain("[<identifier>](/<prefix>/issues/<identifier>)");
|
||||
}
|
||||
});
|
||||
|
||||
it("says on the interaction listing that only the addressee resolves a connection request card", () => {
|
||||
const list = searchRunnerApi({ query: "GET /api/issues/{id}/interactions" }).results.find(row => row.operationId === "GET /api/issues/{id}/interactions");
|
||||
expect(list?.description).toContain("Only that person can connect or decline it");
|
||||
});
|
||||
|
||||
it("names only pages that the board UI mounts", () => {
|
||||
const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8");
|
||||
const pages = new Set(
|
||||
runnerApiCatalog()
|
||||
.filter(row => /^\/api\/(mcp\/(setup|requests\/\{id\}\/consent)$|connection-intents\/|tool-connections\/\{connectionId\}\/(catalog|review-counts)$|companies\/\{companyId\}\/(tools\/apps\/attention|tools\/apps\/connect|tools\/connections|chat-endpoints)$)/.test(row.path))
|
||||
.flatMap(row => [...(row.description ?? "").matchAll(/\/<prefix>\/([a-z0-9/<>-]+)/gi)].map(match => match[1]!)),
|
||||
);
|
||||
expect([...pages].sort()).toEqual(["apps/<connectionId>/permissions", "apps/assistant-connection", "apps/byo", "company/settings/instance/experimental", "issues/<identifier>"]);
|
||||
const mounted = (page: string) => page
|
||||
.replace("apps/<connectionId>/permissions", "apps/:connectionId/:tab")
|
||||
.replace("issues/<identifier>", "issues/:issueId");
|
||||
for (const page of pages) expect(appRoutes, page).toContain(`<Route path="${mounted(page)}" element=`);
|
||||
expect(readFileSync(path.join(repoRoot, "ui/src/pages/apps/app-tabs.ts"), "utf8")).toContain("\"permissions\"");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"connect custom MCP server with server URL and API key BYO connection",
|
||||
"byo custom mcp server create tool app connection",
|
||||
])("search %j sends an own-server API key to the secure page", (query) => {
|
||||
const found = searchRunnerApi({ query, limit: 10 }).results.filter(row => ["POST /api/companies/{companyId}/tools/apps/connect", "POST /api/companies/{companyId}/tools/connections"].includes(row.operationId));
|
||||
expect(found.length, query).toBeGreaterThan(0);
|
||||
for (const operation of found) expect(operation.description).toContain("[Connect your own MCP server](/<prefix>/apps/byo)");
|
||||
});
|
||||
|
||||
it("links only mounted pages from connections_search", () => {
|
||||
const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8");
|
||||
const search = readFileSync(path.join(repoRoot, "server/src/services/connection-intents.ts"), "utf8");
|
||||
for (const page of ["apps/byo", "company/settings/instance/experimental"]) {
|
||||
expect(search, page).toContain(`}/${page})`);
|
||||
expect(appRoutes, page).toContain(`<Route path="${page}" element=`);
|
||||
}
|
||||
});
|
||||
@@ -144,6 +144,16 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
if (!enabled) throw forbidden("This connector is not enabled on this instance");
|
||||
}
|
||||
|
||||
function assertChatProviderEnabledForCreate(
|
||||
provider: string,
|
||||
experimental: { enableChatConnectors: boolean; enableGitHubReviewBots: boolean },
|
||||
) {
|
||||
if (provider === "agentmail") return;
|
||||
if (provider === "github" ? experimental.enableGitHubReviewBots : experimental.enableChatConnectors) return;
|
||||
const setting = provider === "github" ? "GitHub review bots" : "Chat connectors";
|
||||
throw forbidden(`${setting} is turned off on this instance, so this chat endpoint cannot be created. An instance admin turns it on in Experimental settings at /<prefix>/company/settings/instance/experimental. Agents cannot change instance settings.`);
|
||||
}
|
||||
|
||||
async function assertConnectionManager(
|
||||
req: ExpressRequest,
|
||||
companyId: string,
|
||||
@@ -195,6 +205,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertConnectionManager(req, companyId);
|
||||
assertChatProviderEnabledForCreate(req.body.provider, await instanceSettingsService(db).getExperimental());
|
||||
if (req.body.provider === "slack" && !req.body.slackSetupMethod) {
|
||||
req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod();
|
||||
}
|
||||
|
||||
@@ -2359,7 +2359,9 @@ registry.registerPath({
|
||||
tags: ["chat-channels"],
|
||||
summary: "Create a chat endpoint",
|
||||
description:
|
||||
"Creates one provider bot endpoint bound permanently to one Paperclip agent. Provider setup and verification happen in later calls.",
|
||||
"Creates one provider bot endpoint bound permanently to one Paperclip agent. Provider setup and verification happen in later calls. "
|
||||
+ "Chat providers need Chat connectors, and GitHub needs GitHub review bots, turned on by an instance admin in Experimental settings at /<prefix>/company/settings/instance/experimental. "
|
||||
+ "While that setting is off, connections_search shows the provider as unavailable and this call is refused; agents cannot change it. Link that page as [Experimental settings](/<prefix>/company/settings/instance/experimental) and reply.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string().uuid() }),
|
||||
body: jsonBody(createChatEndpointSchema),
|
||||
@@ -8327,6 +8329,7 @@ registry.registerPath({
|
||||
path: "/api/issues/{id}/interactions",
|
||||
tags: ["issues"],
|
||||
summary: "List issue thread interactions",
|
||||
description: "A connection_intent interaction is a connection request card addressed to one person, its addresseeUserId. Only that person can connect or decline it; no other member, board user or agent can resolve it for them.",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
@@ -11329,11 +11332,17 @@ registerCurrentRoute({
|
||||
body: connectionRequestInputSchema,
|
||||
});
|
||||
|
||||
const ADDRESSED_CONNECTION_REQUEST = "Person-only: a connection request card is addressed to one person, its addresseeUserId. "
|
||||
+ "Only that person, signed in to their own browser, can open its setup, connect their account or decline it. The server refuses every other member, board user and agent, so nobody can finish it for them. "
|
||||
+ "When someone else offers to finish it, tell them the addressee must connect their own account from the card on its task, linked as [<identifier>](/<prefix>/issues/<identifier>). "
|
||||
+ "Do not create another request for the addressee and do not search for another route.";
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/connection-intents/{interactionId}/setup-options",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Get setup options for an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -11341,6 +11350,7 @@ registerCurrentRoute({
|
||||
path: "/api/connection-intents/{interactionId}/phase",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Update the setup phase for an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
body: z
|
||||
.object({
|
||||
phase: z.enum(["requested", "authorizing", "needs_retry"]),
|
||||
@@ -11362,6 +11372,7 @@ registerCurrentRoute({
|
||||
path: "/api/connection-intents/{interactionId}/complete",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Complete an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
body: completeConnectionIntentSchema,
|
||||
});
|
||||
|
||||
@@ -11387,6 +11398,7 @@ registerCurrentRoute({
|
||||
path: "/api/connection-intents/{interactionId}/decline",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Decline an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
body: declineConnectionIntentSchema,
|
||||
});
|
||||
|
||||
@@ -11634,6 +11646,9 @@ registerCurrentRoute({
|
||||
"Inspect a curated app's public MCP and OAuth metadata without credentials or registration",
|
||||
});
|
||||
|
||||
const OWN_MCP_SERVER_KEY = "When a person's own MCP server needs an API key, the person adds it themselves on [Connect your own MCP server](/<prefix>/apps/byo): they enter its URL and type the key into that page's secure field. "
|
||||
+ "An agent cannot enter the key and must not ask for it in chat. Link that page and reply instead of creating the connection here.";
|
||||
|
||||
registerCurrentRoute({
|
||||
"x-paperclip-transport": "rest",
|
||||
"x-paperclip-connection-management": "connections:configure",
|
||||
@@ -11641,6 +11656,7 @@ registerCurrentRoute({
|
||||
path: "/api/companies/{companyId}/tools/apps/connect",
|
||||
tags: ["tool-access"],
|
||||
summary: "Create a draft app connection from gallery input",
|
||||
description: OWN_MCP_SERVER_KEY,
|
||||
body: connectToolAppSchema,
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
@@ -11688,6 +11704,11 @@ registerCurrentRoute({
|
||||
},
|
||||
});
|
||||
|
||||
const QUARANTINED_ACTION_REVIEW = "Catalog entries with status quarantined are new actions that a catalog refresh found and holds for a person's review. "
|
||||
+ "No agent can call a quarantined action, through this connection or any other transport, until a person who manages the connection allows it on the app's Permissions page, "
|
||||
+ "[Permissions](/<prefix>/apps/<connectionId>/permissions), with this conversation's company prefix and the connection's id. Allowing it is that person's decision, not an agent's. "
|
||||
+ "When asked to use a quarantined action, say it is waiting for review, link that page and reply; do not search for another route.";
|
||||
|
||||
registerCurrentRoute({
|
||||
"x-paperclip-transport": "rest",
|
||||
"x-paperclip-connection-management": "connections:read",
|
||||
@@ -11695,6 +11716,7 @@ registerCurrentRoute({
|
||||
path: "/api/companies/{companyId}/tools/apps/attention",
|
||||
tags: ["tool-access"],
|
||||
summary: "List tool apps needing attention",
|
||||
description: QUARANTINED_ACTION_REVIEW,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -11806,6 +11828,7 @@ registerCurrentRoute({
|
||||
path: "/api/companies/{companyId}/tools/connections",
|
||||
tags: ["tool-access"],
|
||||
summary: "Create a tool connection",
|
||||
description: OWN_MCP_SERVER_KEY,
|
||||
body: createToolConnectionSchema,
|
||||
responses: {
|
||||
201: r.ok(),
|
||||
@@ -11910,6 +11933,7 @@ registerCurrentRoute({
|
||||
path: "/api/tool-connections/{connectionId}/review-counts",
|
||||
tags: ["tool-access"],
|
||||
summary: "Get pending action request and quarantined catalog entry counts for one connection",
|
||||
description: QUARANTINED_ACTION_REVIEW,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -12029,6 +12053,7 @@ registerCurrentRoute({
|
||||
path: "/api/tool-connections/{connectionId}/catalog",
|
||||
tags: ["tool-access"],
|
||||
summary: "List a tool connection catalog",
|
||||
description: QUARANTINED_ACTION_REVIEW,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -12901,9 +12926,16 @@ registerCurrentRoute({
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
const ASSISTANT_CONNECTION_SETUP_PERSON_ONLY = "Person-only: a person connects their own assistant, such as Claude, ChatGPT or another MCP client, in their browser on "
|
||||
+ "[Assistant connections](/<prefix>/apps/assistant-connection), with this conversation's company prefix as <prefix>. "
|
||||
+ "That page gives the invitation and server URL to paste into the assistant; the assistant then opens a Paperclip consent screen where the person picks the company and approves access. "
|
||||
+ "The consent screen offers only access within that person's own role: a read-only member can grant read access but not writes. "
|
||||
+ "An agent cannot read this setup or approve consent for anyone: these routes accept only the person's own signed-in browser session. "
|
||||
+ "Do not search for another route; link that page, say the person approves access there themselves, and reply.";
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/mcp/setup", tags: ["tool-gateway"],
|
||||
summary: "Read assistant connection setup using a human browser session",
|
||||
description: ASSISTANT_CONNECTION_SETUP_PERSON_ONLY,
|
||||
// Available while disabled; returns metadata only and never grants access.
|
||||
responses: {
|
||||
200: r.ok(z.object({ enabled: z.boolean(), serverUrl: z.string().url(), invitationUrl: z.string().url(), invitation: z.string() })),
|
||||
@@ -12944,6 +12976,7 @@ for (const suffix of ["/dot-pairing/preview", "/dot-pairing"]) registerCurrentRo
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/mcp/requests/{id}/consent", tags: ["tool-gateway"],
|
||||
summary: "Approve or deny assistant access using a same-origin browser session",
|
||||
description: ASSISTANT_CONNECTION_SETUP_PERSON_ONLY,
|
||||
body: mcpConsentSchema,
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
@@ -543,6 +543,21 @@ export function connectionIntentService(db: Db) {
|
||||
connectionId: ready?.id ?? null,
|
||||
}});
|
||||
}
|
||||
if (!settings.enableChatConnectors) for (const app of APP_STORE_DEFINITIONS) {
|
||||
if (!isConnectorFeatureEnabled(app, settings) || discoveryMethods(app).length || findAggregatorService(app.slug) || findAggregatorService(app.name)
|
||||
|| !getAvailableConnectionMethods(app).some(method => method.purpose === "channel" && method.provider !== "github")) continue;
|
||||
const channelMethods = getAvailableConnectionMethods(app).filter(method => method.purpose === "channel");
|
||||
const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name],
|
||||
`${app.description ?? ""} ${channelMethods.map(method => `${method.whenToUse} ${method.label ?? ""}`).join(" ")}`);
|
||||
if (!score) continue;
|
||||
candidates.push({ score, nameScore, item: {
|
||||
service: app.slug, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null,
|
||||
methods: [], source: "catalog", state: "unavailable", connectionId: null,
|
||||
reason: `${app.name} chat is turned off on this instance. An instance admin turns it on with Chat connectors on [Experimental settings](/${company.prefix}/company/settings/instance/experimental); `
|
||||
+ "then a person who manages connections can set it up. Agents cannot change instance settings. "
|
||||
+ "Tell the person this, link that page and reply; do not create a chat endpoint, substitute another channel or search for another route.",
|
||||
}});
|
||||
}
|
||||
// A yielded native service is not offered for new setup when an aggregator
|
||||
// route can serve it; its routes take its place in ranked results, so broad
|
||||
// and capability-only searches still find the app. Without a route the
|
||||
@@ -588,7 +603,7 @@ export function connectionIntentService(db: Db) {
|
||||
// Aggregator routes need a recorded provider choice before connection_request.
|
||||
const rankedResult = (suggestions = false) => {
|
||||
const results = ranked();
|
||||
return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, suggestions);
|
||||
return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, company.prefix, suggestions);
|
||||
};
|
||||
const targetService = publicService?.slug ?? (indexedMatches.length === 1 ? indexedMatches[0]!.slug : normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-"));
|
||||
// Indexed-only app labels must be identical when requests re-search the slug.
|
||||
@@ -696,13 +711,15 @@ export function connectionIntentService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
function directSearchResult(query: string, results: ConnectionSearchResultItem[], suggestions = false): ConnectionsSearchResult {
|
||||
function directSearchResult(query: string, results: ConnectionSearchResultItem[], prefix: string, suggestions = false): ConnectionsSearchResult {
|
||||
const ownServer = `the person adds it as their own MCP server on [Connect your own MCP server](/${prefix}/apps/byo): they enter the URL and type any API key into that page's secure field themselves. `
|
||||
+ "An agent cannot enter the key. Do not create the connection yourself, ask for the key in chat or search for another route; link that page and reply.";
|
||||
return { version: 1, query, results, instruction: !results.length
|
||||
? "No verified connection route was found. Explain that support could not be verified; do not invent a provider route or request unsupported services."
|
||||
? `No verified connection route was found. If the person gave the service's own server URL, ${ownServer} Otherwise explain that support could not be verified; do not invent a provider route or request unsupported services.`
|
||||
: !suggestions && isRemoteMcpConnectorId(results[0]!.service) && results[0]!.state !== "unavailable"
|
||||
? `${results[0]!.name} is an external service. When the user explicitly names this provider, disclose that it handles the connection and requests to the requested app; no additional provider-choice question is necessary. ${results[0]!.state === "ready" ? aggregatorContinuationInstruction(results[0]!.service, "The requested app") : "Call connection_request with the returned service identifier and follow its instruction. Provider setup does not yet verify underlying app access."}`
|
||||
: !suggestions && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
|
||||
: suggestions || results.length > 1 ? "These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support."
|
||||
: (!suggestions || results.length === 1) && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
|
||||
: suggestions || results.length > 1 ? `These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support. If none of these is the service the person named and they gave that service's own server URL, ${ownServer}`
|
||||
: results[0]!.state === "ready" && results[0]!.methods.every(method => method.purpose === "ai")
|
||||
? "AI authentication is available for this agent's next execution. Do not create another connection request or ask the user to reconnect."
|
||||
: results[0]!.service === "agentmail"
|
||||
@@ -712,7 +729,6 @@ export function connectionIntentService(db: Db) {
|
||||
: results[0]!.methods.every(method => method.purpose && method.purpose !== "tool")
|
||||
? "Choose the method relevant to the task using its purpose and label. If method.management.allowed, configure through search_api/call_api using nextApiOperation. Hand off only required human authentication or consent at the saved setupPath. These methods do not use the tool connection_request card. Do not claim tools or an inbox are ready before setup finishes."
|
||||
: results[0]!.state === "ready" ? "Use the installed connection. Do not create another connection request."
|
||||
: results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
|
||||
: "The user asked to connect. If method.management.allowed, use search_api/call_api with nextApiOperation to configure it. Reuse a saved draft. Use connection_request only for required human input or access. Do not ask a generic confirmation or silently change policy to bypass a failed tool call." };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user