feat(apps): support pasted MCP OAuth continuation

Add a credential-safe opt-in generic Notion live smoke and focused regression coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent bca6c0e991
commit 699f009275
7 files changed
+1764 -1

No files matched your search

+38
View File
@@ -182,3 +182,41 @@ in-process MCP server plus authorization server. It needs no network and no
vendor credentials, and every case connects by URL without naming a gallery app.
A credentialed vendor smoke (for example live PostHog OAuth) may be recorded by
QA but is not required for deterministic verification.
### Opt-in generic Notion live smoke
`pnpm smoke:notion-generic-live` exercises the generic **Advanced → Paste a
config** route against `https://mcp.notion.com/mcp`. It is intentionally outside
the normal unit, browser, and CI-required suites. Run it only against an
already-running, browser-reachable HTTPS Paperclip instance with these bindings
provided by the execution environment:
- `PAPERCLIP_E2E_BASE_URL`, `PAPERCLIP_E2E_EMAIL`, and
`PAPERCLIP_DEV_LOGIN_PASSWORD` for the target instance;
- `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_RUN_ID`, and
`PAPERCLIP_TASK_ID` for the control plane;
- the approved on-demand secret binding
`access.notion_generic_flow_test_account`, delivered by the agent secret API
under its normalized key `generic-flow-test-account`, for the existing Notion
test account.
Check the URL, health endpoint, and binding metadata without retrieving the
credential value or opening a browser:
```sh
pnpm smoke:notion-generic-live -- --dry-run
```
The live command retrieves the credential only after the safe preflight and
Paperclip login succeed. It disables trace, video, and HAR capture, takes only
post-callback screenshots, enables and invokes only `notion-get-self`, proves
`notion-create-pages` remains locally denied, and removes its uniquely named
connection in a `finally` cleanup. Its `summary.json` and PNG files contain
sanitized IDs, decisions, outcomes, and endpoint origins/paths only; they
default to `PAPERCLIP_RUN_SCRATCH_DIR`, or to `NOTION_EVIDENCE_DIR` when set.
Run the credential-free harness checks with:
```sh
node --test scripts/smoke/notion-generic-live.test.mjs
```
+1
View File
@@ -51,6 +51,7 @@
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"smoke:mcp-fixtures": "node scripts/smoke/mcp-fixture-harness.mjs",
"smoke:notion-generic-live": "node scripts/smoke/notion-generic-live.mjs",
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
+343
View File
@@ -0,0 +1,343 @@
const REQUIRED_ENVIRONMENT = [
"PAPERCLIP_E2E_BASE_URL",
"PAPERCLIP_E2E_EMAIL",
"PAPERCLIP_DEV_LOGIN_PASSWORD",
"PAPERCLIP_API_URL",
"PAPERCLIP_API_KEY",
"PAPERCLIP_RUN_ID",
"PAPERCLIP_TASK_ID",
];
// Agent secret APIs expose access.notion_generic_flow_test_account under this
// normalized delivery key; the live harness never reads any other binding.
export const NOTION_SECRET_BINDING_KEY = "generic-flow-test-account";
export class NotionGenericLivePreflightError extends Error {
constructor(code, details = {}) {
super(code);
this.name = "NotionGenericLivePreflightError";
this.code = code;
this.details = details;
}
}
function requiredValue(environment, key) {
const value = environment[key];
return typeof value === "string" ? value.trim() : "";
}
function explicitHttpsOrigin(raw, code) {
let value;
try {
value = new URL(raw);
} catch {
throw new NotionGenericLivePreflightError(code);
}
if (value.protocol !== "https:"
|| value.username
|| value.password
|| value.search
|| value.hash
|| (value.pathname !== "/" && value.pathname !== "/api" && value.pathname !== "/api/")) {
throw new NotionGenericLivePreflightError(code);
}
return value;
}
export function preflightNotionGenericLive(environment = process.env) {
const missing = REQUIRED_ENVIRONMENT.filter((key) => requiredValue(environment, key) === "");
if (missing.length > 0) {
throw new NotionGenericLivePreflightError("missing_environment", { missing });
}
const base = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_E2E_BASE_URL"), "unsafe_base_url");
const api = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_API_URL"), "unsafe_api_url");
const email = requiredValue(environment, "PAPERCLIP_E2E_EMAIL");
if (!email.includes("@")) throw new NotionGenericLivePreflightError("invalid_paperclip_email");
return {
baseUrl: base.origin,
apiBaseUrl: `${api.origin}/api`,
callbackUrl: `${base.origin}/api/tools/oauth/callback`,
paperclipEmail: email,
paperclipPassword: environment.PAPERCLIP_DEV_LOGIN_PASSWORD,
agentApiKey: environment.PAPERCLIP_API_KEY,
runId: environment.PAPERCLIP_RUN_ID,
taskId: environment.PAPERCLIP_TASK_ID,
secretBindingKey: NOTION_SECRET_BINDING_KEY,
};
}
async function responseJson(response, code) {
try {
return await response.json();
} catch {
throw new NotionGenericLivePreflightError(code);
}
}
async function fetchWithTimeout(fetchImpl, url, init) {
try {
return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(15_000) });
} catch {
throw new NotionGenericLivePreflightError("request_failed");
}
}
export async function prepareNotionGenericLiveSmoke({
environment = process.env,
fetchImpl = globalThis.fetch,
loadBrowser,
}) {
const config = preflightNotionGenericLive(environment);
const healthResponse = await fetchWithTimeout(
fetchImpl,
new URL("/api/health", config.baseUrl),
{ headers: { accept: "application/json" } },
);
if (!healthResponse.ok) {
throw new NotionGenericLivePreflightError("health_http_error", { status: healthResponse.status });
}
const health = await responseJson(healthResponse, "health_invalid_json");
if (health?.status !== "ok") throw new NotionGenericLivePreflightError("health_not_ok");
const secretsResponse = await fetchWithTimeout(
fetchImpl,
`${config.apiBaseUrl}/agents/me/secrets`,
{
headers: {
accept: "application/json",
authorization: `Bearer ${config.agentApiKey}`,
},
},
);
if (!secretsResponse.ok) {
throw new NotionGenericLivePreflightError("secret_metadata_http_error", { status: secretsResponse.status });
}
const secretMetadata = await responseJson(secretsResponse, "secret_metadata_invalid_json");
const available = Array.isArray(secretMetadata?.secrets)
&& secretMetadata.secrets.some((entry) => entry?.key === config.secretBindingKey && entry?.delivery === "api");
if (!available) throw new NotionGenericLivePreflightError("secret_binding_unavailable");
return { config, browserModule: await loadBrowser() };
}
export async function fetchNotionTestCredentials(config, fetchImpl = globalThis.fetch) {
const response = await fetchWithTimeout(
fetchImpl,
`${config.apiBaseUrl}/agents/me/secrets/${encodeURIComponent(config.secretBindingKey)}/value`,
{
method: "POST",
headers: {
accept: "application/json",
authorization: `Bearer ${config.agentApiKey}`,
},
},
);
if (!response.ok) {
throw new NotionGenericLivePreflightError("secret_value_http_error", { status: response.status });
}
const body = await responseJson(response, "secret_value_invalid_json");
if (typeof body?.value !== "string") {
throw new NotionGenericLivePreflightError("secret_value_missing");
}
let credential;
try {
credential = JSON.parse(body.value);
} catch {
throw new NotionGenericLivePreflightError("secret_value_invalid_shape");
}
const username = [credential?.email, credential?.username, credential?.login]
.find((value) => typeof value === "string" && value.trim());
const password = typeof credential?.password === "string" ? credential.password : "";
if (!username || !password) {
throw new NotionGenericLivePreflightError("secret_value_invalid_shape");
}
return { username: username.trim(), password };
}
export function assertAutomaticRegistrationSource(source) {
if (source !== "cimd" && source !== "dcr") {
throw new NotionGenericLivePreflightError("unexpected_registration_source");
}
return source;
}
export function safeEndpointSummary(raw, label) {
let endpoint;
try {
endpoint = new URL(raw);
} catch {
throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`);
}
if (endpoint.protocol !== "https:"
|| endpoint.username
|| endpoint.password
|| endpoint.search
|| endpoint.hash) {
throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`);
}
return { origin: endpoint.origin, path: endpoint.pathname };
}
export function inspectAuthorizationUrl(raw, {
callbackUrl,
resource,
registrationSource,
baseUrl,
}) {
let target;
try {
target = new URL(raw);
} catch {
throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint");
}
if (target.protocol !== "https:" || target.username || target.password || target.hash) {
throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint");
}
const required = ["client_id", "state", "code_challenge", "redirect_uri", "resource"];
if (required.some((key) => !target.searchParams.get(key))) {
throw new NotionGenericLivePreflightError("authorization_parameter_missing");
}
if (target.searchParams.get("code_challenge_method") !== "S256") {
throw new NotionGenericLivePreflightError("pkce_s256_missing");
}
if (target.searchParams.get("redirect_uri") !== callbackUrl) {
throw new NotionGenericLivePreflightError("callback_uri_mismatch");
}
if (target.searchParams.get("resource") !== resource) {
throw new NotionGenericLivePreflightError("resource_mismatch");
}
if (target.searchParams.get("response_type") !== "code") {
throw new NotionGenericLivePreflightError("response_type_mismatch");
}
if (registrationSource === "cimd") {
const expectedClientId = new URL("/api/tools/oauth/client-metadata", baseUrl).toString();
if (target.searchParams.get("client_id") !== expectedClientId) {
throw new NotionGenericLivePreflightError("cimd_client_id_mismatch");
}
}
return {
endpoint: { origin: target.origin, path: target.pathname },
parameters: {
clientId: true,
state: true,
pkceS256: true,
callbackUri: true,
resource: true,
},
};
}
function parsedJsonString(value) {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > 200_000) return null;
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
for (const candidate of fenced ? [fenced[1], trimmed] : [trimmed]) {
if (!candidate.startsWith("{") && !candidate.startsWith("[")) continue;
try {
return JSON.parse(candidate);
} catch {
// A later recursive branch may still contain structured content.
}
}
return null;
}
export function extractNotionIdentity(value) {
const seen = new Set();
const facts = { workspaceId: null, workspaceName: null, botId: null };
const visit = (candidate, depth) => {
if (depth > 12 || candidate === null || candidate === undefined) return;
if (typeof candidate === "string") {
const parsed = parsedJsonString(candidate);
if (parsed !== null) visit(parsed, depth + 1);
return;
}
if (typeof candidate !== "object" || seen.has(candidate)) return;
seen.add(candidate);
if (!Array.isArray(candidate)) {
const workspaceId = candidate.workspace_id ?? candidate.workspaceId;
const workspaceName = candidate.workspace_name ?? candidate.workspaceName;
if (!facts.workspaceId && typeof workspaceId === "string" && workspaceId.trim()) facts.workspaceId = workspaceId.trim();
if (!facts.workspaceName && typeof workspaceName === "string" && workspaceName.trim()) facts.workspaceName = workspaceName.trim();
if (!facts.botId && candidate.type === "bot" && typeof candidate.id === "string" && candidate.id.trim()) {
facts.botId = candidate.id.trim();
}
}
for (const child of Array.isArray(candidate) ? candidate : Object.values(candidate)) visit(child, depth + 1);
};
visit(value, 0);
return (facts.workspaceId || facts.botId) && facts.workspaceName ? facts : null;
}
export function parseSanitizedAgentProof(commentBody, expectedIdentity) {
if (typeof commentBody !== "string") return null;
const trimmed = commentBody.trim();
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
let parsed;
try {
parsed = JSON.parse(fenced ? fenced[1] : trimmed);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (Object.keys(parsed).sort().join(",") !== "invocationId,workspaceId,workspaceName") return null;
if (parsed.workspaceId !== expectedIdentity.workspaceId || parsed.workspaceName !== expectedIdentity.workspaceName) return null;
if (typeof parsed.invocationId !== "string" || !parsed.invocationId.trim()) return null;
return {
workspaceId: parsed.workspaceId,
workspaceName: parsed.workspaceName,
invocationId: parsed.invocationId.trim(),
};
}
export function parseRuntimeAbsenceProof(commentBody, connectionId) {
if (typeof commentBody !== "string") return null;
let parsed;
try {
parsed = JSON.parse(commentBody.trim());
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (Object.keys(parsed).sort().join(",") !== "connectionId,toolPresent") return null;
return parsed.connectionId === connectionId && parsed.toolPresent === false
? { connectionId, toolPresent: false }
: null;
}
const FORBIDDEN_EVIDENCE_KEYS = /(?:password|access[_-]?token|refresh[_-]?token|authorization|cookie|oauth[_-]?code|client[_-]?secret|session)/i;
const FORBIDDEN_EVIDENCE_TEXT = /(?:authorization:\s*bearer|cookie:|[?&](?:code|state|token|access_token|refresh_token)=)/i;
export function assertSanitizedEvidence(value) {
const seen = new Set();
const visit = (candidate, path) => {
if (candidate === null || candidate === undefined) return;
if (typeof candidate === "string") {
if (FORBIDDEN_EVIDENCE_TEXT.test(candidate)) throw new Error(`unsafe_evidence_text:${path}`);
return;
}
if (typeof candidate !== "object" || seen.has(candidate)) return;
seen.add(candidate);
for (const [key, child] of Object.entries(candidate)) {
if (FORBIDDEN_EVIDENCE_KEYS.test(key)) throw new Error(`unsafe_evidence_key:${path}.${key}`);
visit(child, `${path}.${key}`);
}
};
visit(value, "$");
}
export function preflightFailureMessage(error) {
if (!(error instanceof NotionGenericLivePreflightError)) {
return "Notion generic live smoke preflight failed.";
}
if (error.code === "missing_environment") {
return `Notion generic live smoke preflight failed: missing ${error.details.missing.join(", ")}.`;
}
if (error.code === "health_http_error") {
return `Notion generic live smoke preflight failed: /api/health returned HTTP ${error.details.status}.`;
}
return `Notion generic live smoke preflight failed: ${error.code}.`;
}
+959
View File
@@ -0,0 +1,959 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
assertAutomaticRegistrationSource,
assertSanitizedEvidence,
extractNotionIdentity,
fetchNotionTestCredentials,
inspectAuthorizationUrl,
NotionGenericLivePreflightError,
parseRuntimeAbsenceProof,
parseSanitizedAgentProof,
preflightFailureMessage,
prepareNotionGenericLiveSmoke,
safeEndpointSummary,
} from "./notion-generic-live-lib.mjs";
const TARGET_COMPANY_PREFIX = "PAP";
const TARGET_AGENT_NAME = "CodexCoderPro";
const NOTION_RESOURCE = "https://mcp.notion.com/mcp";
const NOTION_GET_SELF = "notion-get-self";
const NOTION_CREATE_PAGES = "notion-create-pages";
const DEFAULT_AGENT_TIMEOUT_MS = 15 * 60_000;
class SmokeFailure extends Error {
constructor(checkpoint, code) {
super(`${checkpoint}:${code}`);
this.name = "SmokeFailure";
this.checkpoint = checkpoint;
this.code = code;
}
}
function fail(checkpoint, code) {
throw new SmokeFailure(checkpoint, code);
}
function asArray(value, key) {
if (Array.isArray(value)) return value;
if (value && typeof value === "object" && Array.isArray(value[key])) return value[key];
return [];
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function screenshotFile(outputDirectory, name) {
return path.join(outputDirectory, name);
}
function assertNoCredentialMaterial(value, credentials, checkpoint) {
const serialized = JSON.stringify(value);
for (const credential of credentials) {
if (credential && serialized.includes(credential)) fail(checkpoint, "credential_material_visible");
}
if (/[?&](?:code|state|access_token|refresh_token)=/i.test(serialized)) {
fail(checkpoint, "oauth_query_material_visible");
}
const forbiddenValueKey = /^(?:password|accessToken|access_token|refreshToken|refresh_token|oauthCode|oauth_code|clientSecret|client_secret|cookie|authorizationHeader)$/i;
const visit = (candidate, seen = new Set()) => {
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return;
seen.add(candidate);
for (const [key, child] of Object.entries(candidate)) {
if (forbiddenValueKey.test(key) && child !== null && child !== "") {
fail(checkpoint, "raw_credential_field_visible");
}
visit(child, seen);
}
};
visit(value);
}
function issueMutationHeaders(config, method, pathname) {
if (method === "GET" || !pathname.startsWith("/api/issues/")) return {};
return { "X-Paperclip-Run-Id": config.runId };
}
async function apiJson(request, config, method, pathname, data, checkpoint, expectedStatuses = [200]) {
let response;
try {
response = await request.fetch(new URL(pathname, config.baseUrl).toString(), {
method,
...(data === undefined ? {} : { data }),
headers: {
accept: "application/json",
...issueMutationHeaders(config, method, pathname),
},
timeout: 30_000,
});
} catch {
fail(checkpoint, "request_failed");
}
if (!expectedStatuses.includes(response.status())) {
fail(checkpoint, `http_${response.status()}`);
}
try {
return await response.json();
} catch {
fail(checkpoint, "invalid_json");
}
}
async function waitFor(checkpoint, fn, { timeoutMs = 120_000, intervalMs = 2_000 } = {}) {
const deadline = Date.now() + timeoutMs;
let lastValue;
while (Date.now() < deadline) {
lastValue = await fn();
if (lastValue) return lastValue;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
fail(checkpoint, "timed_out");
return lastValue;
}
async function expectVisible(locator, checkpoint, code, timeout = 30_000) {
try {
await locator.waitFor({ state: "visible", timeout });
} catch {
fail(checkpoint, code);
}
}
async function clickVisibleButton(page, names) {
for (const name of names) {
const button = page.getByRole("button", { name, exact: false }).filter({ visible: true }).first();
if (!await button.count()) continue;
try {
await button.click({ timeout: 2_000 });
return true;
} catch {
// Provider pages replace controls while advancing between login steps.
}
}
return false;
}
async function completeNotionAuthorization(page, config, credential) {
const paperclipOrigin = new URL(config.baseUrl).origin;
const deadline = Date.now() + 6 * 60_000;
while (Date.now() < deadline) {
let current;
try {
current = new URL(page.url());
} catch {
fail("C.oauth-callback", "invalid_navigation_url");
}
if (current.origin === paperclipOrigin && current.pathname.includes("/apps/")) return;
const bodyText = await page.locator("body").innerText().catch(() => "");
if (/check your (?:email|inbox)|verification code|one-time code/i.test(bodyText)) {
fail("C.notion-login", "interactive_code_required");
}
const usernameInput = page.locator([
'input[type="email"]',
'input[name="email"]',
'input[name="username"]',
'input[autocomplete="username"]',
].join(",")).filter({ visible: true }).first();
if (await usernameInput.count()) {
const currentValue = await usernameInput.inputValue().catch(() => "");
if (!currentValue) await usernameInput.fill(credential.username);
}
const passwordInput = page.locator([
'input[type="password"]',
'input[name="password"]',
'input[autocomplete="current-password"]',
].join(",")).filter({ visible: true }).first();
if (await passwordInput.count()) {
const currentValue = await passwordInput.inputValue().catch(() => "");
if (!currentValue) await passwordInput.fill(credential.password);
await clickVisibleButton(page, [/^sign in$/i, /^log in$/i, /^continue$/i]);
} else if (await usernameInput.count()) {
await clickVisibleButton(page, [/continue with email/i, /^continue$/i, /^next$/i, /^sign in$/i]);
} else {
await clickVisibleButton(page, [
/^authorize$/i,
/^allow$/i,
/allow access/i,
/^approve$/i,
/^grant access$/i,
/^accept$/i,
/^continue$/i,
/^select$/i,
]);
}
await page.waitForTimeout(600);
}
fail("C.oauth-callback", "provider_authorization_timed_out");
}
async function safeScreenshot(page, outputPath, config, credential, checkpoint) {
const current = new URL(page.url());
if (current.origin !== new URL(config.baseUrl).origin) fail(checkpoint, "screenshot_not_on_paperclip");
for (const queryKey of ["code", "state", "token", "access_token", "refresh_token"]) {
if (current.searchParams.has(queryKey)) fail(checkpoint, "credential_query_in_screenshot_url");
}
const bodyText = await page.locator("body").innerText();
if (bodyText.includes(config.paperclipPassword)
|| bodyText.includes(credential.password)
|| /[?&](?:code|state|access_token|refresh_token)=/i.test(bodyText)) {
fail(checkpoint, "credential_material_in_screenshot");
}
await page.screenshot({
path: outputPath,
fullPage: true,
animations: "disabled",
mask: [
page.getByText(config.paperclipEmail, { exact: false }),
page.getByText(credential.username, { exact: false }),
],
});
}
function assertGenericProvenance(connectResult, connectionName) {
if (connectResult?.application?.name !== connectionName) fail("B.generic-provenance", "application_name_mismatch");
if (!String(connectResult?.application?.applicationKey ?? "").startsWith("app-gallery:link:")) {
fail("B.generic-provenance", "application_key_not_generic");
}
if (connectResult?.application?.metadata?.source !== "link") fail("B.generic-provenance", "metadata_source_not_link");
const config = connectResult?.connection?.config;
if (connectResult?.connection?.transport !== "mcp_remote"
|| config?.url !== NOTION_RESOURCE
|| config?.unverifiedServer !== true
|| config?.galleryKey === "notion"
|| config?.sourceTemplateKey === "notion") {
fail("B.generic-provenance", "connection_not_unverified_generic");
}
}
function oauthEndpointProof(connection, startResult, config) {
const source = assertAutomaticRegistrationSource(startResult.registrationSource);
const oauth = connection?.config?.oauth;
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) fail("C.oauth-proof", "oauth_config_missing");
if (connection.authKind !== "oauth" || oauth.clientRegistrationSource !== source) {
fail("C.oauth-proof", "registration_source_not_persisted");
}
if (oauth.resource !== NOTION_RESOURCE || startResult.resource !== NOTION_RESOURCE) {
fail("C.oauth-proof", "resource_mismatch");
}
if (oauth.clientRedirectUri !== config.callbackUrl) fail("C.oauth-proof", "client_callback_mismatch");
if (source === "cimd" && oauth.clientIdMetadataDocumentSupported !== true) {
fail("C.oauth-proof", "cimd_support_not_persisted");
}
const endpoints = {
issuer: safeEndpointSummary(oauth.issuer, "issuer"),
metadata: safeEndpointSummary(oauth.metadataUrl, "metadata"),
authorize: safeEndpointSummary(oauth.authorizationUrl, "authorize"),
exchange: safeEndpointSummary(oauth.tokenUrl, "exchange"),
...(oauth.registrationUrl ? { registration: safeEndpointSummary(oauth.registrationUrl, "registration") } : {}),
};
return { source, endpoints };
}
function catalogFacts(catalog, checkpoint) {
const active = catalog.filter((entry) => entry.status !== "removed");
const getSelf = active.find((entry) => entry.toolName === NOTION_GET_SELF);
const createPages = active.find((entry) => entry.toolName === NOTION_CREATE_PAGES);
if (!getSelf || !getSelf.isReadOnly) fail(checkpoint, "notion_get_self_missing_or_not_read_only");
if (!createPages || createPages.isReadOnly) fail(checkpoint, "notion_create_pages_missing_or_not_write");
return { active, getSelf, createPages };
}
async function finishAgentOnlySetup(request, config, companyId, connectionId, catalog, agentId) {
const facts = catalogFacts(catalog, "D.catalog-policy");
await apiJson(
request,
config,
"POST",
`/api/companies/${companyId}/tools/apps/${connectionId}/finish`,
{
enabledCatalogEntryIds: [facts.getSelf.id],
askFirstCatalogEntryIds: [],
reviewedCatalogEntryIds: facts.active
.filter((entry) => entry.status === "quarantined")
.map((entry) => entry.id),
access: { agentIds: [agentId] },
},
"D.catalog-policy",
);
await apiJson(
request,
config,
"PUT",
`/api/tool-connections/${connectionId}/installs`,
{ installs: [{ targetType: "agent", targetId: agentId }] },
"D.agent-install",
);
return facts;
}
async function createAndWaitForProofIssue(request, config, input, checkpoint) {
const child = await apiJson(
request,
config,
"POST",
`/api/issues/${config.taskId}/children`,
input,
`${checkpoint}.create`,
[201],
);
if (child.status !== "todo") fail(`${checkpoint}.create`, "child_not_created_todo");
const observedStatuses = new Set(["todo"]);
const finished = await waitFor(`${checkpoint}.run`, async () => {
const issue = await apiJson(request, config, "GET", `/api/issues/${child.id}`, undefined, `${checkpoint}.run`);
observedStatuses.add(issue.status);
if (["blocked", "cancelled"].includes(issue.status)) fail(`${checkpoint}.run`, `child_${issue.status}`);
return issue.status === "done" ? issue : null;
}, {
timeoutMs: Number(process.env.NOTION_AGENT_TIMEOUT_MS || DEFAULT_AGENT_TIMEOUT_MS),
intervalMs: 3_000,
});
if (!finished.startedAt || !finished.completedAt) fail(`${checkpoint}.run`, "transition_timestamps_missing");
const commentsResponse = await apiJson(
request,
config,
"GET",
`/api/issues/${child.id}/comments`,
undefined,
`${checkpoint}.comment`,
);
return { child, finished, observedStatuses, comments: asArray(commentsResponse, "comments") };
}
async function cleanupConnection(request, config, companyId, connectionId, connectionName) {
const removed = await apiJson(
request,
config,
"DELETE",
`/api/tool-connections/${connectionId}`,
undefined,
"F.cleanup",
);
const receipt = removed.removal;
if (!receipt
|| receipt.installsRemoved !== 1
|| receipt.appProfileBindingsRemoved < 1
|| receipt.credentialRefsCleared + receipt.secretsRevoked < 1
|| !["deleted", "archived"].includes(receipt.appProfile)) {
fail("F.cleanup", "incomplete_removal_receipt");
}
const connections = await apiJson(
request,
config,
"GET",
`/api/companies/${companyId}/tools/connections`,
undefined,
"F.cleanup-verification",
);
if (asArray(connections, "connections").some((candidate) =>
candidate.id === connectionId
&& candidate.name === connectionName
&& candidate.status !== "archived")) {
fail("F.cleanup-verification", "test_connection_remains");
}
const profiles = await apiJson(
request,
config,
"GET",
`/api/companies/${companyId}/tools/profiles`,
undefined,
"F.cleanup-verification",
);
if (asArray(profiles, "profiles").some((profile) => profile.profileKey === `app:${connectionId}` && profile.status === "active")) {
fail("F.cleanup-verification", "active_profile_remains");
}
const pending = await apiJson(
request,
config,
"GET",
`/api/companies/${companyId}/tools/action-requests?status=pending`,
undefined,
"F.cleanup-verification",
);
if (asArray(pending, "actionRequests").some((item) => (item.connectionId ?? item.request?.connectionId) === connectionId)) {
fail("F.cleanup-verification", "pending_action_remains");
}
return {
completed: true,
credentialsRemoved: receipt.credentialRefsCleared + receipt.secretsRevoked,
secretBindingsRemoved: receipt.secretBindingsRemoved,
grantsRevoked: receipt.grantsRevoked,
accessBindingsRemoved: receipt.appProfileBindingsRemoved,
installsRemoved: receipt.installsRemoved,
oauthStatesDiscarded: receipt.oauthStatesDiscarded,
runtimeSlotsStopped: receipt.runtimeSlotsStopped,
appProfile: receipt.appProfile,
pendingActions: 0,
remainingConnections: 0,
};
}
async function runSmoke({ config, chromium }) {
const startedAt = new Date();
const runKey = startedAt.toISOString().replace(/[:.]/g, "-");
const connectionName = `Notion generic self-test ${startedAt.toISOString()}`;
const outputDirectory = process.env.NOTION_EVIDENCE_DIR
? path.resolve(process.env.NOTION_EVIDENCE_DIR)
: path.join(process.env.PAPERCLIP_RUN_SCRATCH_DIR || process.cwd(), `notion-generic-live-${runKey}`);
await mkdir(outputDirectory, { recursive: true });
const summary = {
schemaVersion: 1,
smoke: "notion_generic_mcp_live",
passed: false,
startedAt: startedAt.toISOString(),
completedAt: null,
target: { companyPrefix: TARGET_COMPANY_PREFIX, resource: NOTION_RESOURCE },
importPreview: null,
connection: null,
oauthProof: null,
catalog: null,
boardTest: null,
freshRun: null,
cleanup: null,
postCleanupRun: null,
screenshots: [],
failure: null,
};
let browser;
let context;
let credential = null;
let connectionId = null;
let companyId = null;
let cleanupComplete = false;
let caughtFailure = null;
try {
browser = await chromium.launch({ headless: process.env.NOTION_SMOKE_HEADED !== "1" });
context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
acceptDownloads: false,
serviceWorkers: "block",
});
const page = await context.newPage();
await page.goto(new URL("/auth?next=/", config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
const paperclipEmailInput = page.getByLabel(/^email$/i);
const paperclipPasswordInput = page.getByLabel(/^password$/i);
await expectVisible(paperclipEmailInput, "A.paperclip-login", "email_field_missing");
await expectVisible(paperclipPasswordInput, "A.paperclip-login", "password_field_missing");
await paperclipEmailInput.fill(config.paperclipEmail);
await paperclipPasswordInput.fill(config.paperclipPassword);
const loginResponsePromise = page.waitForResponse((response) =>
response.request().method() === "POST" && new URL(response.url()).pathname === "/api/auth/sign-in/email",
);
await page.getByRole("button", { name: /^sign in$/i }).click();
const loginResponse = await loginResponsePromise;
if (!loginResponse.ok()) fail("A.paperclip-login", `http_${loginResponse.status()}`);
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: 30_000 }).catch(() => {
fail("A.paperclip-login", "login_redirect_missing");
});
const companies = await apiJson(context.request, config, "GET", "/api/companies", undefined, "A.company-selection");
const company = asArray(companies, "companies").find((candidate) => candidate.issuePrefix === TARGET_COMPANY_PREFIX);
if (!company) fail("A.company-selection", "pap_company_missing");
companyId = company.id;
const agents = await apiJson(
context.request,
config,
"GET",
`/api/companies/${companyId}/agents`,
undefined,
"A.agent-selection",
);
const agent = asArray(agents, "agents").find((candidate) => candidate.name === TARGET_AGENT_NAME);
if (!agent) fail("A.agent-selection", "codex_coder_pro_missing");
const existingConnections = await apiJson(
context.request,
config,
"GET",
`/api/companies/${companyId}/tools/connections`,
undefined,
"A.connection-isolation",
);
const existingIds = new Set(asArray(existingConnections, "connections").map((entry) => entry.id));
if (asArray(existingConnections, "connections").some((entry) => entry.name === connectionName && entry.status !== "archived")) {
fail("A.connection-isolation", "connection_name_collision");
}
// Fetch only after URL, health, Paperclip login, company, agent, and binding
// metadata have all passed. The value remains in this process and is never
// written to browser artifacts or command arguments.
credential = await fetchNotionTestCredentials(config);
await page.goto(
new URL(`/${TARGET_COMPANY_PREFIX}/apps/advanced/paste-config`, config.baseUrl).toString(),
{ waitUntil: "domcontentloaded" },
);
await expectVisible(page.getByRole("heading", { name: "Advanced setup" }), "B.paste-config", "advanced_setup_missing");
const configTextarea = page.locator("textarea").first();
await expectVisible(configTextarea, "B.paste-config", "config_textarea_missing");
const exactConfig = JSON.stringify({ mcpServers: { notion: { url: NOTION_RESOURCE } } }, null, 2);
await configTextarea.fill(exactConfig);
const importResponsePromise = page.waitForResponse((response) =>
response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/companies/${companyId}/tools/mcp/import-json`,
);
await page.getByRole("button", { name: /^check config$/i }).click();
const importResponse = await importResponsePromise;
if (!importResponse.ok()) fail("B.import-preview", `http_${importResponse.status()}`);
const preview = await importResponse.json().catch(() => fail("B.import-preview", "invalid_json"));
const drafts = asArray(preview, "drafts");
if (drafts.length !== 1
|| drafts[0].name !== "notion"
|| drafts[0].transport !== "mcp_remote"
|| drafts[0].config?.url !== NOTION_RESOURCE
|| drafts[0].credentialFields?.length !== 0
|| drafts[0].credentialRefs?.length !== 0) {
fail("B.import-preview", "unexpected_draft");
}
const afterPreview = await apiJson(
context.request,
config,
"GET",
`/api/companies/${companyId}/tools/connections`,
undefined,
"B.import-preview",
);
if (asArray(afterPreview, "connections").some((entry) => !existingIds.has(entry.id))) {
fail("B.import-preview", "preview_created_connection");
}
summary.importPreview = {
draftCount: 1,
name: "notion",
transport: "mcp_remote",
resource: NOTION_RESOURCE,
credentialFieldCount: 0,
credentialRefCount: 0,
};
const connectionNameInput = page.getByText("Connection name", { exact: true }).locator("input");
await expectVisible(connectionNameInput, "B.generic-connect", "connection_name_input_missing");
await connectionNameInput.fill(connectionName);
const connectResponsePromise = page.waitForResponse((response) =>
response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/companies/${companyId}/tools/apps/connect`,
);
const oauthStartResponsePromise = page.waitForResponse((response) =>
response.request().method() === "POST"
&& /^\/api\/tools\/oauth\/[^/]+\/start$/.test(new URL(response.url()).pathname),
{ timeout: 60_000 },
);
await page.getByRole("button", { name: /^check actions$/i }).click();
const connectResponse = await connectResponsePromise;
if (!connectResponse.ok()) fail("B.generic-connect", `http_${connectResponse.status()}`);
const connectResult = await connectResponse.json().catch(() => fail("B.generic-connect", "invalid_json"));
connectionId = connectResult.connectionId;
if (typeof connectionId !== "string" || !connectionId) fail("B.generic-connect", "connection_id_missing");
assertGenericProvenance(connectResult, connectionName);
if (connectResult.auth?.kind !== "oauth") fail("B.generic-connect", "oauth_challenge_missing");
const oauthStartResponse = await oauthStartResponsePromise.catch(() => null);
if (!oauthStartResponse || !oauthStartResponse.ok()) fail("C.oauth-start", "start_response_missing");
const startResult = await oauthStartResponse.json().catch(() => fail("C.oauth-start", "invalid_json"));
if (startResult.connectionId !== connectionId) fail("C.oauth-start", "connection_id_changed");
const registrationSource = assertAutomaticRegistrationSource(startResult.registrationSource);
const navigationProof = inspectAuthorizationUrl(startResult.authorizationUrl, {
callbackUrl: config.callbackUrl,
resource: NOTION_RESOURCE,
registrationSource,
baseUrl: config.baseUrl,
});
// Registration source and every URL/PKCE/state invariant are checked before
// the credential is entered into the provider page.
await completeNotionAuthorization(page, config, credential);
const cleanSetupPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/setup`;
await page.goto(new URL(cleanSetupPath, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
await expectVisible(page.getByText("OAuth connected", { exact: true }), "C.oauth-callback", "connected_state_missing", 45_000);
await expectVisible(page.getByText("Unverified server", { exact: true }), "C.oauth-callback", "unverified_badge_missing");
let connection = await apiJson(
context.request,
config,
"GET",
`/api/tool-connections/${connectionId}`,
undefined,
"C.connection-detail",
);
if (connection.name !== connectionName
|| connection.config?.unverifiedServer !== true
|| connection.config?.sourceTemplateKey === "notion"
|| connection.authKind !== "oauth") {
fail("C.connection-detail", "generic_provenance_lost");
}
const endpointProof = oauthEndpointProof(connection, startResult, config);
assertNoCredentialMaterial(connection, [config.paperclipPassword, credential.username, credential.password], "C.connection-detail");
summary.connection = {
id: connectionId,
applicationId: connection.applicationId,
name: connectionName,
applicationKey: connectResult.application.applicationKey,
metadataSource: "link",
transport: "mcp_remote",
unverifiedServer: true,
status: connection.status,
healthStatus: connection.healthStatus,
};
summary.oauthProof = {
registrationSource,
resource: NOTION_RESOURCE,
callbackUriExact: true,
pkceS256: true,
statePresent: true,
endpoint: navigationProof.endpoint,
parameters: navigationProof.parameters,
discoveredEndpoints: endpointProof.endpoints,
};
const connectedShot = "01-generic-connected.png";
await safeScreenshot(page, screenshotFile(outputDirectory, connectedShot), config, credential, "F.connected-screenshot");
summary.screenshots.push(connectedShot);
const health = await apiJson(
context.request,
config,
"POST",
`/api/tool-connections/${connectionId}/health-check`,
{},
"D.health-check",
);
if (health.connection?.healthStatus !== "healthy") fail("D.health-check", "connection_not_healthy");
const refreshed = await apiJson(
context.request,
config,
"POST",
`/api/tool-connections/${connectionId}/catalog/refresh`,
{},
"D.catalog-refresh",
);
const catalog = asArray(refreshed, "catalog");
const facts = await finishAgentOnlySetup(context.request, config, companyId, connectionId, catalog, agent.id);
connection = await apiJson(context.request, config, "GET", `/api/tool-connections/${connectionId}`, undefined, "D.connection-active");
if (connection.status !== "active" || connection.healthStatus !== "healthy" || connection.config?.unverifiedServer !== true) {
fail("D.connection-active", "connection_not_active_healthy_generic");
}
const uniqueConnections = await apiJson(
context.request,
config,
"GET",
`/api/companies/${companyId}/tools/connections`,
undefined,
"D.connection-active",
);
if (asArray(uniqueConnections, "connections").filter((candidate) => candidate.name === connectionName).length !== 1) {
fail("D.connection-active", "duplicate_connection_detected");
}
const installs = await apiJson(
context.request,
config,
"GET",
`/api/tool-connections/${connectionId}/installs`,
undefined,
"D.agent-install",
);
const installRows = asArray(installs, "installs");
if (installRows.length !== 1 || installRows[0].targetType !== "agent" || installRows[0].targetId !== agent.id) {
fail("D.agent-install", "install_not_agent_only");
}
const testAgents = await apiJson(
context.request,
config,
"GET",
`/api/tool-connections/${connectionId}/test-agents`,
undefined,
"D.effective-policy",
);
const testAgent = asArray(testAgents, "agents").find((candidate) => candidate.id === agent.id);
const getSelfAccess = testAgent?.effectiveAccess?.tools?.find((tool) => tool.toolName === NOTION_GET_SELF);
const createPagesAccess = testAgent?.effectiveAccess?.tools?.find((tool) => tool.toolName === NOTION_CREATE_PAGES);
if (getSelfAccess?.decision !== "allowed" || createPagesAccess?.decision !== "off") {
fail("D.effective-policy", "unexpected_effective_decision");
}
const deniedWrite = await apiJson(
context.request,
config,
"POST",
`/api/tool-connections/${connectionId}/test-calls`,
{ agentId: agent.id, toolName: NOTION_CREATE_PAGES, parameters: {} },
"D.write-denial",
);
if (deniedWrite.decision !== "off" || typeof deniedWrite.invocationId !== "string" || !deniedWrite.error) {
fail("D.write-denial", "write_action_not_denied");
}
summary.catalog = {
discoveredCount: refreshed.discoveredCount,
advertisedReadTools: facts.active
.filter((entry) => [NOTION_GET_SELF, "notion-get-users", "notion-fetch"].includes(entry.toolName))
.map((entry) => entry.toolName),
getSelf: { catalogEntryId: facts.getSelf.id, decision: "allowed" },
createPages: {
catalogEntryId: facts.createPages.id,
decision: "off",
localDenialInvocationId: deniedWrite.invocationId,
upstreamSent: false,
},
accessAgentId: agent.id,
installAgentId: agent.id,
enabledCatalogEntryCount: 1,
healthCheck: "healthy",
catalogRefresh: "succeeded",
};
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
await expectVisible(page.getByText("Who can use it", { exact: true }), "D.permissions-ui", "permissions_panel_missing");
const getSelfPermission = page.locator(`[data-action-id="${facts.getSelf.id}"] select`);
const createPagesPermission = page.locator(`[data-action-id="${facts.createPages.id}"] select`);
await expectVisible(getSelfPermission, "D.permissions-ui", "get_self_permission_missing");
await expectVisible(createPagesPermission, "D.permissions-ui", "create_pages_permission_missing");
if (await getSelfPermission.inputValue() !== "allowed" || await createPagesPermission.inputValue() !== "off") {
fail("D.permissions-ui", "permissions_ui_mismatch");
}
const permissionsShot = "02-read-only-policy.png";
await safeScreenshot(page, screenshotFile(outputDirectory, permissionsShot), config, credential, "F.permissions-screenshot");
summary.screenshots.push(permissionsShot);
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/test`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
await expectVisible(page.getByLabel("Choose which agent to test as"), "D.test-panel", "agent_picker_missing");
await page.getByLabel("Choose which agent to test as").click();
await page.getByLabel("Search agents").fill(TARGET_AGENT_NAME);
await page.getByRole("button", { name: new RegExp(`^${escapeRegex(TARGET_AGENT_NAME)}`) }).click();
await page.getByLabel("Find an action").fill(NOTION_GET_SELF);
const getSelfTitle = facts.getSelf.title ?? facts.getSelf.toolName;
const actionRow = page.locator("button").filter({ hasText: getSelfTitle }).filter({ hasText: "Allowed" }).first();
await expectVisible(actionRow, "D.test-panel", "get_self_allowed_row_missing");
await actionRow.click();
await expectVisible(page.getByText("This action takes no inputs."), "D.test-panel", "empty_input_form_missing");
const boardStartedAt = Date.now();
const testCallResponsePromise = page.waitForResponse((response) =>
response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/tool-connections/${connectionId}/test-calls`,
);
await page.getByRole("button", { name: /^run$/i }).click();
const testCallResponse = await testCallResponsePromise;
if (!testCallResponse.ok()) fail("D.board-get-self", `http_${testCallResponse.status()}`);
const testCall = await testCallResponse.json().catch(() => fail("D.board-get-self", "invalid_json"));
const testCallInput = testCallResponse.request().postDataJSON();
if (testCallInput?.agentId !== agent.id
|| testCallInput?.toolName !== NOTION_GET_SELF
|| !testCallInput.parameters
|| Object.keys(testCallInput.parameters).length !== 0) {
fail("D.board-get-self", "nonempty_or_unexpected_input");
}
if (testCall.decision !== "allowed" || testCall.error || typeof testCall.invocationId !== "string") {
fail("D.board-get-self", "gateway_call_not_allowed");
}
const boardIdentity = extractNotionIdentity(testCall.result);
if (!boardIdentity) fail("D.board-get-self", "workspace_identity_missing");
await expectVisible(page.getByText(/^Worked\./), "D.board-get-self", "success_result_missing");
const boardShot = "03-board-notion-get-self.png";
await safeScreenshot(page, screenshotFile(outputDirectory, boardShot), config, credential, "F.board-test-screenshot");
summary.screenshots.push(boardShot);
summary.boardTest = {
catalogEntryId: facts.getSelf.id,
toolName: NOTION_GET_SELF,
invocationId: testCall.invocationId,
decision: "allowed",
resultStatus: "succeeded",
workspace: boardIdentity,
durationMs: Date.now() - boardStartedAt,
};
const proofIssue = await createAndWaitForProofIssue(context.request, config, {
title: `Notion generic installed-tool proof ${startedAt.toISOString()}`,
description: [
"Invoke exactly one installed action: the read-only `notion-get-self` tool with an empty `{}` input.",
"Make no Notion mutation and do not invoke any other Notion action.",
`Require workspace ID ${boardIdentity.workspaceId} and workspace name ${boardIdentity.workspaceName}.`,
"Post exactly one JSON object with keys `workspaceId`, `workspaceName`, and `invocationId` (the Paperclip invocation ID), then mark this issue done.",
"Do not report tokens, cookies, headers, authorization data, raw payloads, or any other fields.",
].join("\n\n"),
status: "todo",
workMode: "standard",
priority: "medium",
assigneeAgentId: agent.id,
acceptanceCriteria: [
"The installed notion-get-self action succeeds with empty input.",
"Only sanitized workspace ID/name and Paperclip invocation ID are reported.",
"No Notion mutation is attempted.",
],
}, "E.fresh-agent");
for (const comment of proofIssue.comments) {
assertNoCredentialMaterial(comment.body, [credential.username, credential.password], "E.agent-proof-comment");
}
const proof = proofIssue.comments
.filter((comment) => comment.authorAgentId === agent.id || comment.derivedAuthorAgentId === agent.id)
.map((comment) => parseSanitizedAgentProof(comment.body, boardIdentity))
.find(Boolean);
if (!proof) fail("E.agent-proof-comment", "sanitized_proof_missing");
let finalActivity;
const agentEvent = await waitFor("E.agent-audit", async () => {
const activity = await apiJson(
context.request,
config,
"GET",
`/api/tool-connections/${connectionId}/activity?limit=100`,
undefined,
"E.agent-audit",
);
assertNoCredentialMaterial(activity, [credential.username, credential.password], "E.agent-audit");
finalActivity = activity;
return asArray(activity, "events").find((event) =>
event.issueId === proofIssue.child.id
&& event.agentId === agent.id
&& event.toolName === NOTION_GET_SELF
&& event.invocationId === proof.invocationId
&& event.outcome === "success") ?? null;
}, { timeoutMs: 60_000, intervalMs: 2_000 });
if (!agentEvent.runId) fail("E.agent-audit", "run_id_missing");
if (agentEvent.requestSummary?.summary !== "{}") fail("E.agent-audit", "get_self_input_not_empty");
const childToolEvents = asArray(finalActivity, "events").filter((event) =>
event.issueId === proofIssue.child.id && event.invocationId);
if (childToolEvents.length !== 1 || childToolEvents[0].toolName !== NOTION_GET_SELF) {
fail("E.agent-audit", "unexpected_upstream_action");
}
const agentRun = await waitFor("E.agent-run-status", async () => {
const run = await apiJson(context.request, config, "GET", `/api/heartbeat-runs/${agentEvent.runId}`, undefined, "E.agent-run-status");
if (["failed", "cancelled", "timed_out"].includes(run.status)) fail("E.agent-run-status", `run_${run.status}`);
return run.status === "succeeded" ? run : null;
}, { timeoutMs: 60_000, intervalMs: 2_000 });
summary.freshRun = {
issueId: proofIssue.child.id,
issueIdentifier: proofIssue.child.identifier,
transition: {
created: "todo",
enteredInProgress: proofIssue.observedStatuses.has("in_progress") || Boolean(proofIssue.finished.startedAt),
completed: "done",
},
runId: agentEvent.runId,
runStatus: agentRun.status,
invocationId: proof.invocationId,
workspace: { id: proof.workspaceId, name: proof.workspaceName },
connectionId,
decision: agentEvent.decision ?? "allowed",
outcome: agentEvent.outcome,
durationMs: agentEvent.latencyMs,
};
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/issues/${proofIssue.child.identifier}`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
await expectVisible(page.getByText(proofIssue.child.title, { exact: true }).first(), "F.child-screenshot", "child_issue_missing");
const childShot = "04-fresh-agent-proof.png";
await safeScreenshot(page, screenshotFile(outputDirectory, childShot), config, credential, "F.child-screenshot");
summary.screenshots.push(childShot);
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/activity`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
await expectVisible(page.getByText(NOTION_GET_SELF, { exact: false }).first(), "F.activity-screenshot", "get_self_activity_missing");
const activityShot = "05-redacted-audit.png";
await safeScreenshot(page, screenshotFile(outputDirectory, activityShot), config, credential, "F.activity-screenshot");
summary.screenshots.push(activityShot);
summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName);
cleanupComplete = true;
const absenceIssue = await createAndWaitForProofIssue(context.request, config, {
title: `Notion generic cleanup runtime proof ${startedAt.toISOString()}`,
description: [
`Inspect the tools available in this fresh runtime after generic connection ${connectionId} was removed.`,
"Do not call any Notion tool. Confirm `notion-get-self` from that removed connection is absent.",
`Post exactly {\"connectionId\":\"${connectionId}\",\"toolPresent\":false} and mark this issue done.`,
"Do not report any other fields or credential material.",
].join("\n\n"),
status: "todo",
workMode: "standard",
priority: "medium",
assigneeAgentId: agent.id,
acceptanceCriteria: [
"A fresh runtime does not expose the removed generic connection's notion-get-self tool.",
"No Notion action is invoked.",
],
}, "F.post-cleanup-runtime");
const absenceProof = absenceIssue.comments
.filter((comment) => comment.authorAgentId === agent.id || comment.derivedAuthorAgentId === agent.id)
.map((comment) => parseRuntimeAbsenceProof(comment.body, connectionId))
.find(Boolean);
if (!absenceProof) fail("F.post-cleanup-runtime", "absence_proof_missing");
const afterCleanupActivity = await apiJson(
context.request,
config,
"GET",
`/api/tool-connections/${connectionId}/activity?limit=100`,
undefined,
"F.post-cleanup-runtime",
);
if (asArray(afterCleanupActivity, "events").some((event) => event.issueId === absenceIssue.child.id && event.invocationId)) {
fail("F.post-cleanup-runtime", "post_cleanup_tool_invocation_detected");
}
summary.postCleanupRun = {
issueId: absenceIssue.child.id,
issueIdentifier: absenceIssue.child.identifier,
connectionId,
toolPresent: false,
invocationCount: 0,
completed: "done",
};
summary.passed = true;
} catch (error) {
caughtFailure = error instanceof SmokeFailure
? error
: error instanceof NotionGenericLivePreflightError
? new SmokeFailure("A.secret-binding", error.code)
: new SmokeFailure("unexpected", "unexpected_error");
} finally {
if (connectionId && companyId && context && !cleanupComplete) {
try {
summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName);
cleanupComplete = true;
} catch {
summary.cleanup = { completed: false, code: "cleanup_failed" };
if (!caughtFailure) caughtFailure = new SmokeFailure("F.cleanup", "cleanup_failed");
}
}
await context?.close().catch(() => {});
await browser?.close().catch(() => {});
}
summary.completedAt = new Date().toISOString();
if (caughtFailure) summary.failure = { checkpoint: caughtFailure.checkpoint, code: caughtFailure.code };
assertSanitizedEvidence(summary);
const summaryPath = path.join(outputDirectory, "summary.json");
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, { mode: 0o600 });
if (caughtFailure) throw caughtFailure;
return { outputDirectory, summaryPath, screenshots: summary.screenshots };
}
async function main() {
const dryRun = process.argv.includes("--dry-run");
let prepared;
try {
prepared = await prepareNotionGenericLiveSmoke({
loadBrowser: dryRun ? async () => null : () => import("@playwright/test"),
});
} catch (error) {
process.stderr.write(`${preflightFailureMessage(error)}\n`);
process.exitCode = error instanceof NotionGenericLivePreflightError ? 2 : 1;
return;
}
if (dryRun) {
process.stdout.write("Notion generic live smoke dry-run passed; no credential value was fetched.\n");
return;
}
try {
const result = await runSmoke({ config: prepared.config, chromium: prepared.browserModule.chromium });
process.stdout.write(`Notion generic live smoke passed. Sanitized evidence: ${result.outputDirectory}\n`);
} catch (error) {
const failure = error instanceof SmokeFailure ? error : new SmokeFailure("unexpected", "unexpected_error");
process.stderr.write(`Notion generic live smoke failed at ${failure.checkpoint} (${failure.code}).\n`);
process.exitCode = 1;
}
}
await main();
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assertAutomaticRegistrationSource,
assertSanitizedEvidence,
extractNotionIdentity,
inspectAuthorizationUrl,
NotionGenericLivePreflightError,
parseRuntimeAbsenceProof,
parseSanitizedAgentProof,
preflightNotionGenericLive,
prepareNotionGenericLiveSmoke,
safeEndpointSummary,
} from "./notion-generic-live-lib.mjs";
const COMPLETE_ENV = {
PAPERCLIP_E2E_BASE_URL: "https://paperclip.example.test",
PAPERCLIP_E2E_EMAIL: "operator@example.test",
PAPERCLIP_DEV_LOGIN_PASSWORD: "not-a-real-password",
PAPERCLIP_API_URL: "https://paperclip.example.test/api",
PAPERCLIP_API_KEY: "not-a-real-agent-key",
PAPERCLIP_RUN_ID: "run-123",
PAPERCLIP_TASK_ID: "issue-123",
};
test("preflight reports binding names without exposing supplied values", () => {
assert.throws(
() => preflightNotionGenericLive({ PAPERCLIP_DEV_LOGIN_PASSWORD: "present" }),
(error) => {
assert.ok(error instanceof NotionGenericLivePreflightError);
assert.equal(error.code, "missing_environment");
assert.deepEqual(error.details.missing, [
"PAPERCLIP_E2E_BASE_URL",
"PAPERCLIP_E2E_EMAIL",
"PAPERCLIP_API_URL",
"PAPERCLIP_API_KEY",
"PAPERCLIP_RUN_ID",
"PAPERCLIP_TASK_ID",
]);
assert.doesNotMatch(error.message, /present/);
return true;
},
);
});
test("preflight requires explicit credential-free HTTPS target and control-plane URLs", () => {
for (const baseUrl of [
"http://127.0.0.1:3100",
"http://paperclip.example.test",
"https://user:secret@paperclip.example.test",
"https://paperclip.example.test/?code=secret",
]) {
assert.throws(
() => preflightNotionGenericLive({ ...COMPLETE_ENV, PAPERCLIP_E2E_BASE_URL: baseUrl }),
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_base_url",
);
}
const split = preflightNotionGenericLive({
...COMPLETE_ENV,
PAPERCLIP_API_URL: "https://control-plane.example.test/api",
});
assert.equal(split.baseUrl, "https://paperclip.example.test");
assert.equal(split.apiBaseUrl, "https://control-plane.example.test/api");
});
test("health and binding metadata pass before browser loading, without fetching the value", async () => {
const requests = [];
let browserLoaded = false;
const prepared = await prepareNotionGenericLiveSmoke({
environment: COMPLETE_ENV,
fetchImpl: async (url, init = {}) => {
requests.push({ url: String(url), method: init.method ?? "GET" });
if (String(url).endsWith("/api/health")) {
return { ok: true, json: async () => ({ status: "ok" }) };
}
return {
ok: true,
json: async () => ({ secrets: [{ key: "generic-flow-test-account", delivery: "api" }] }),
};
},
loadBrowser: async () => {
browserLoaded = true;
return { chromium: {} };
},
});
assert.equal(prepared.config.callbackUrl, "https://paperclip.example.test/api/tools/oauth/callback");
assert.equal(browserLoaded, true);
assert.deepEqual(requests.map((entry) => entry.method), ["GET", "GET"]);
assert.equal(requests.some((entry) => entry.url.includes("/value")), false);
});
test("an unavailable secret binding fails before browser or credential entry", async () => {
let browserLoaded = false;
await assert.rejects(
prepareNotionGenericLiveSmoke({
environment: COMPLETE_ENV,
fetchImpl: async (url) => String(url).endsWith("/api/health")
? { ok: true, json: async () => ({ status: "ok" }) }
: { ok: true, json: async () => ({ secrets: [] }) },
loadBrowser: async () => {
browserLoaded = true;
},
}),
(error) => error instanceof NotionGenericLivePreflightError && error.code === "secret_binding_unavailable",
);
assert.equal(browserLoaded, false);
});
test("authorization proof requires automatic registration, PKCE, callback, resource, and safe endpoints", () => {
assert.equal(assertAutomaticRegistrationSource("cimd"), "cimd");
assert.equal(assertAutomaticRegistrationSource("dcr"), "dcr");
for (const source of ["manual", "preconfigured", null]) {
assert.throws(
() => assertAutomaticRegistrationSource(source),
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unexpected_registration_source",
);
}
const baseUrl = "https://paperclip.example.test";
const callbackUrl = `${baseUrl}/api/tools/oauth/callback`;
const resource = "https://mcp.notion.com/mcp";
const url = new URL("https://mcp.notion.com/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", `${baseUrl}/api/tools/oauth/client-metadata`);
url.searchParams.set("redirect_uri", callbackUrl);
url.searchParams.set("state", "not-recorded");
url.searchParams.set("code_challenge", "not-recorded");
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("resource", resource);
assert.deepEqual(inspectAuthorizationUrl(url.toString(), {
callbackUrl,
resource,
registrationSource: "cimd",
baseUrl,
}), {
endpoint: { origin: "https://mcp.notion.com", path: "/authorize" },
parameters: { clientId: true, state: true, pkceS256: true, callbackUri: true, resource: true },
});
assert.deepEqual(safeEndpointSummary("https://mcp.notion.com/token", "token"), {
origin: "https://mcp.notion.com",
path: "/token",
});
assert.throws(
() => safeEndpointSummary("http://mcp.notion.com/token", "token"),
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_token_endpoint",
);
});
test("workspace proof extraction and fresh-run comments retain only sanitized identity", () => {
const identity = extractNotionIdentity({
result: {
content: [{
type: "text",
text: JSON.stringify({
id: "bot-123",
type: "bot",
bot: { workspace_id: "workspace-123", workspace_name: "Paperclip" },
token: "discard-me",
}),
}],
},
});
assert.deepEqual(identity, {
workspaceId: "workspace-123",
workspaceName: "Paperclip",
botId: "bot-123",
});
assert.deepEqual(
parseSanitizedAgentProof(
'{"workspaceId":"workspace-123","workspaceName":"Paperclip","invocationId":"inv-123"}',
identity,
),
{ workspaceId: "workspace-123", workspaceName: "Paperclip", invocationId: "inv-123" },
);
assert.deepEqual(
parseRuntimeAbsenceProof('{"connectionId":"conn-123","toolPresent":false}', "conn-123"),
{ connectionId: "conn-123", toolPresent: false },
);
});
test("sanitized evidence rejects credential fields, sessions, and OAuth query values", () => {
assert.doesNotThrow(() => assertSanitizedEvidence({ workspaceId: "workspace-123", invocationId: "inv-123" }));
assert.throws(() => assertSanitizedEvidence({ accessToken: "secret" }), /unsafe_evidence_key/);
assert.throws(() => assertSanitizedEvidence({ sessionId: "secret" }), /unsafe_evidence_key/);
assert.throws(() => assertSanitizedEvidence({ note: "callback?code=secret" }), /unsafe_evidence_text/);
});
+130
View File
@@ -11,10 +11,13 @@ import { PasteConfigTab } from "./PasteConfigTab";
const toolsApiMock = vi.hoisted(() => ({
importMcpJson: vi.fn(),
connectApp: vi.fn(),
startOAuth: vi.fn(),
finishApp: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: navigateTopLevelMock }));
// The tab uses `useNavigate` from the app router (PAP-11088 draft hand-off),
// which needs CompanyProvider; stub it so the copy hint renders in isolation.
vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate }));
@@ -124,6 +127,47 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
};
}
const NOTION_CONFIG = '{ "mcpServers": { "notion": { "url": "https://mcp.notion.com/mcp" } } }';
const NOTION_PREVIEW: McpJsonImportPreview = {
drafts: [{
name: "notion",
transport: "mcp_remote",
status: "draft",
config: { url: "https://mcp.notion.com/mcp" },
credentialRefs: [],
credentialFields: [],
warnings: [],
}],
};
function oauthConnectResult(startUrl: string | null = null): ConnectToolAppResult {
const base = connectResult();
return {
...base,
application: {
...base.application,
applicationKey: "app-gallery:link:notion-generic-test",
name: "notion",
metadata: { source: "link" },
},
connection: {
...base.connection,
authKind: "oauth",
config: { url: "https://mcp.notion.com/mcp", unverifiedServer: true },
transportConfig: { url: "https://mcp.notion.com/mcp", unverifiedServer: true },
},
catalog: [],
actions: { readOnly: [], canMakeChanges: [] },
auth: {
kind: "oauth",
startUrl,
resource: "https://mcp.notion.com/mcp",
registrationSource: "cimd",
},
};
}
describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
let container: HTMLDivElement;
@@ -293,6 +337,92 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
});
});
it("starts OAuth for the original generic connection instead of rendering an empty catalog", async () => {
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
const nameInput = container.querySelector('input[placeholder="notion"]') as HTMLInputElement;
await act(async () => setInputValue(nameInput, "Notion generic self-test 2026-08-17T20:00:00Z"));
await flushReact();
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult());
toolsApiMock.startOAuth.mockResolvedValue({
connectionId: "conn-1",
provider: "mcp.notion.com",
authorizationUrl: "https://mcp.notion.com/authorize?state=redacted",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
registrationSource: "cimd",
});
await act(async () => {
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1);
expect(toolsApiMock.connectApp).toHaveBeenCalledWith("company-1", {
link: "https://mcp.notion.com/mcp",
name: "Notion generic self-test 2026-08-17T20:00:00Z",
credentialValues: {},
});
expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=redacted",
);
expect(container.textContent).toContain("Unverified server");
expect(container.textContent).toContain("mcp.notion.com");
expect(container.textContent).not.toContain("Review actions for notion");
});
it("rejects an unsafe start URL and retries OAuth on the same connection", async () => {
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
await act(async () => {
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(navigateTopLevelMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("couldn’t connect");
expect(container.textContent).not.toContain("javascript:alert(1)");
toolsApiMock.startOAuth.mockResolvedValue({
connectionId: "conn-1",
provider: "mcp.notion.com",
authorizationUrl: "https://mcp.notion.com/authorize?state=retry-redacted",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
registrationSource: "cimd",
});
await act(async () => {
buttonStartingWith("Try again")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1);
expect(toolsApiMock.startOAuth).toHaveBeenCalledTimes(1);
expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=retry-redacted",
);
});
it("cancels to the original connection setup route without creating another draft", async () => {
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
await act(async () => {
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
buttonStartingWith("Back to apps")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup");
expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1);
expect(toolsApiMock.startOAuth).not.toHaveBeenCalled();
});
it("does not offer Continue for a stdio draft (draft-only, no link to hand off)", async () => {
await pasteAndCheck(
{
+106 -1
View File
@@ -13,6 +13,14 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { toolsApi } from "@/api/tools";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { useNavigate } from "@/lib/router";
import {
OAuthConnectStateScreen,
type OAuthConnectPhase,
} from "@/pages/apps/AppsConnect";
import { endpointHost } from "@/pages/apps/generic-mcp-connect";
import { McpConfigHelpDialog } from "./McpConfigHelpDialog";
import { ErrorState } from "./shared";
@@ -92,34 +100,85 @@ function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
* of the two M8 screens where "MCP" vocabulary is allowed (PAP-10827 vocab map).
*/
export function PasteConfigTab({ companyId }: { companyId: string }) {
const navigate = useNavigate();
const [draftText, setDraftText] = useState("");
const [preview, setPreview] = useState<McpJsonImportPreview | null>(null);
const [connectionNames, setConnectionNames] = useState<Record<string, string>>({});
const [credentialValues, setCredentialValues] = useState<Record<string, string>>({});
const [connectResult, setConnectResult] = useState<ConnectToolAppResult | null>(null);
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [activatedName, setActivatedName] = useState<string | null>(null);
const [oauthPhase, setOAuthPhase] = useState<OAuthConnectPhase>("entry");
const [oauthError, setOAuthError] = useState<string | null>(null);
const [authorizationHost, setAuthorizationHost] = useState<string | null>(null);
const importMutation = useMutation({
mutationFn: (mcpJson: string) => toolsApi.importMcpJson(companyId, { mcpJson }),
onSuccess: (result) => {
setPreview(result);
setConnectionNames(Object.fromEntries(result.drafts.map((draft) => [draft.name, draft.name])));
setConnectResult(null);
setActivatedName(null);
},
});
const openAuthorizationPage = (authorizationUrl: string) => {
const target = resolveAuthorizationTarget(authorizationUrl);
if (!target.ok) {
setOAuthPhase("error");
setOAuthError(target.message);
return;
}
setAuthorizationHost(target.host);
setOAuthPhase("redirecting");
navigateTopLevel(target.url);
};
const oauthStartMutation = useMutation({
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
onSuccess: ({ authorizationUrl }) => openAuthorizationPage(authorizationUrl),
onError: (error) => {
setOAuthPhase("error");
setOAuthError(
error instanceof Error
? error.message
: "Paperclip couldn’t start secure sign-in. Try again.",
);
},
});
const connectMutation = useMutation({
mutationFn: (draft: McpJsonImportDraft) => {
const url = draftConnectUrl(draft);
if (!url) throw new Error("Only remote HTTP drafts can be checked and activated from pasted config.");
return toolsApi.connectApp(companyId, {
link: url,
name: draft.name,
name: connectionNames[draft.name]?.trim() || draft.name,
credentialValues: credentialValuesForDraft(draft, credentialValues),
});
},
onSuccess: (result) => {
setConnectResult(result);
if (result.auth?.kind === "oauth") {
setActivatedName(null);
setAuthorizationHost(null);
setOAuthError(null);
if (result.auth.manualClientRequired) {
setOAuthPhase("error");
setOAuthError(
"This server requires OAuth client details from its provider settings. Continue in setup to add them.",
);
return;
}
const startUrl = result.auth.startUrl?.trim();
if (startUrl) {
openAuthorizationPage(startUrl);
} else {
setOAuthPhase("starting");
oauthStartMutation.mutate(result.connectionId);
}
return;
}
const defaults: Record<string, boolean> = {};
for (const action of result.actions.readOnly) defaults[action.catalogEntryId] = true;
for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = false;
@@ -158,6 +217,32 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
}
}, [draftText]);
if (connectResult?.auth?.kind === "oauth") {
const connectionUrl = typeof connectResult.connection.config?.url === "string"
? connectResult.connection.config.url
: typeof connectResult.connection.transportConfig?.url === "string"
? connectResult.connection.transportConfig.url
: "";
return (
<OAuthConnectStateScreen
identity={{
name: connectResult.application.name,
unverifiedHost: endpointHost(connectionUrl),
}}
phase={oauthPhase}
error={oauthError}
authorizationHost={authorizationHost}
onRetry={() => {
setOAuthError(null);
setAuthorizationHost(null);
setOAuthPhase("starting");
oauthStartMutation.mutate(connectResult.connectionId);
}}
onCancel={() => navigate(`/apps/${connectResult.connectionId}/setup`)}
/>
);
}
return (
<div className="space-y-5">
<div className="flex max-w-2xl items-start gap-1.5">
@@ -229,6 +314,10 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
<DraftCard
key={`${draft.name}-${index}`}
draft={draft}
connectionName={connectionNames[draft.name] ?? draft.name}
onConnectionNameChange={(value) =>
setConnectionNames((previous) => ({ ...previous, [draft.name]: value }))
}
credentialValues={credentialValues}
onCredentialChange={(configPath, value) =>
setCredentialValues((prev) => ({ ...prev, [credentialValueKey(draft, configPath)]: value }))
@@ -279,6 +368,8 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
function DraftCard({
draft,
connectionName,
onConnectionNameChange,
credentialValues,
onCredentialChange,
checking,
@@ -286,6 +377,8 @@ function DraftCard({
onCheck,
}: {
draft: McpJsonImportDraft;
connectionName: string;
onConnectionNameChange: (value: string) => void;
credentialValues: Record<string, string>;
onCredentialChange: (configPath: string, value: string) => void;
checking: boolean;
@@ -307,6 +400,18 @@ function DraftCard({
) : null}
</div>
{onCheck ? (
<label className="mt-4 block max-w-sm space-y-1 text-xs font-medium text-foreground">
Connection name
<Input
value={connectionName}
onChange={(event) => onConnectionNameChange(event.target.value)}
placeholder={draft.name}
className="h-8 text-xs"
/>
</label>
) : null}
{draft.credentialFields.length > 0 ? (
<div className="mt-4 space-y-3">
{draft.credentialFields.map((field) => (