mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
feat(mcp) [split 2/8]: add governed access contracts (#9557)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 2/8 and focuses on database schema and shared governance contracts > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: The governed access model needs additive persistence and synchronized shared types before server enforcement can compile. - Proposed solution: Adds migrations 0148–0169, tool-access and Smoke Lab schema, shared types/validators/gallery helpers, and the minimal compile-required contract consumers identified by boundary testing. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/01-demo-servers`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: QA for migrations/validators; Greptile on every PR. ## What Changed - Adds migrations 0148–0169, tool-access and Smoke Lab schema, shared types/validators/gallery helpers, and the minimal compile-required contract consumers identified by boundary testing. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` — passed, including migration numbering and safety checks - `pnpm --filter @paperclipai/db test` — passed - `pnpm --filter @paperclipai/shared test` — passed ## Risks - Migration or contract mistakes could affect every upper layer; all migrations are additive/idempotent and compile consumers are included in this boundary. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > 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 - OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## 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] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] 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 ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7b35de65aa
commit
1de0a3bb1e
81 files changed
+7277
-307
No files matched your search
@@ -65,10 +65,11 @@ const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
|
||||
// Expose the current plugin config so UI components can read operator
|
||||
// settings from the canonical instance config store.
|
||||
ctx.data.register("plugin-config", async () => {
|
||||
const config = await ctx.config.get();
|
||||
// Expose the current company-scoped plugin config so UI components can read
|
||||
// operator settings from the canonical config store.
|
||||
ctx.data.register("plugin-config", async (params) => {
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const config = companyId ? await ctx.config.get(companyId) : null;
|
||||
return {
|
||||
showFilesInSidebar: config?.showFilesInSidebar === true,
|
||||
commentAnnotationMode: config?.commentAnnotationMode ?? "both",
|
||||
|
||||
@@ -422,7 +422,7 @@ function hostFetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
});
|
||||
}
|
||||
|
||||
function useSettingsConfig() {
|
||||
function useSettingsConfig(companyId: string | null) {
|
||||
const [configJson, setConfigJson] = useState<Record<string, unknown>>({ ...DEFAULT_CONFIG });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -430,8 +430,15 @@ function useSettingsConfig() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!companyId) {
|
||||
setLoading(false);
|
||||
setError("Select a company before loading plugin config.");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setLoading(true);
|
||||
hostFetchJson<{ configJson?: Record<string, unknown> | null } | null>(`/api/plugins/${PLUGIN_ID}/config`)
|
||||
hostFetchJson<{ configJson?: Record<string, unknown> | null } | null>(`/api/plugins/${PLUGIN_ID}/config?companyId=${encodeURIComponent(companyId)}`)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setConfigJson({ ...DEFAULT_CONFIG, ...(result?.configJson ?? {}) });
|
||||
@@ -447,14 +454,15 @@ function useSettingsConfig() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [companyId]);
|
||||
|
||||
async function save(nextConfig: Record<string, unknown>) {
|
||||
if (!companyId) throw new Error("Select a company before saving plugin config.");
|
||||
setSaving(true);
|
||||
try {
|
||||
await hostFetchJson(`/api/plugins/${PLUGIN_ID}/config`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ configJson: nextConfig }),
|
||||
body: JSON.stringify({ companyId, configJson: nextConfig }),
|
||||
});
|
||||
setConfigJson(nextConfig);
|
||||
setError(null);
|
||||
@@ -2103,7 +2111,7 @@ export function KitchenSinkPage({ context }: PluginPageProps) {
|
||||
}
|
||||
|
||||
export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
|
||||
const { configJson, setConfigJson, loading, saving, error, save } = useSettingsConfig();
|
||||
const { configJson, setConfigJson, loading, saving, error, save } = useSettingsConfig(context.companyId);
|
||||
const [savedMessage, setSavedMessage] = useState<string | null>(null);
|
||||
|
||||
function setField(key: string, value: unknown) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
definePlugin,
|
||||
runWorker,
|
||||
type PaperclipPlugin,
|
||||
type EnvSecretRefBinding,
|
||||
type PluginContext,
|
||||
type PluginEntityQuery,
|
||||
type PluginEvent,
|
||||
@@ -41,7 +42,7 @@ type KitchenSinkConfig = {
|
||||
showCommentContextMenuItem?: boolean;
|
||||
enableWorkspaceDemos?: boolean;
|
||||
enableProcessDemos?: boolean;
|
||||
secretRefExample?: string;
|
||||
secretRefExample?: string | EnvSecretRefBinding;
|
||||
httpDemoUrl?: string;
|
||||
allowedCommands?: string[];
|
||||
workspaceScratchFile?: string;
|
||||
@@ -91,14 +92,20 @@ function pushRecord(record: Omit<DemoRecord, "id" | "createdAt">): DemoRecord {
|
||||
return next;
|
||||
}
|
||||
|
||||
async function getConfig(ctx: PluginContext): Promise<KitchenSinkConfig> {
|
||||
const config = await ctx.config.get();
|
||||
async function getConfig(ctx: PluginContext, companyId?: string): Promise<KitchenSinkConfig> {
|
||||
const config = await ctx.config.get(companyId);
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...(config as KitchenSinkConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function isSecretRefBinding(value: unknown): value is EnvSecretRefBinding {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
&& (value as { type?: unknown }).type === "secret_ref"
|
||||
&& typeof (value as { secretId?: unknown }).secretId === "string";
|
||||
}
|
||||
|
||||
async function writeInstanceState(ctx: PluginContext, stateKey: string, value: unknown): Promise<void> {
|
||||
await ctx.state.set({ scopeKind: "instance", stateKey }, value);
|
||||
}
|
||||
@@ -248,13 +255,14 @@ function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] {
|
||||
}
|
||||
|
||||
async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
||||
ctx.data.register("plugin-config", async () => {
|
||||
return await getConfig(ctx);
|
||||
ctx.data.register("plugin-config", async (params) => {
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : undefined;
|
||||
return await getConfig(ctx, companyId);
|
||||
});
|
||||
|
||||
ctx.data.register("overview", async (params) => {
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const config = await getConfig(ctx);
|
||||
const config = companyId ? await getConfig(ctx, companyId) : DEFAULT_CONFIG;
|
||||
const companies = await ctx.companies.list({ limit: 200, offset: 0 });
|
||||
const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||
const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : [];
|
||||
@@ -583,7 +591,8 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
||||
});
|
||||
|
||||
ctx.actions.register("http-fetch", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
const url = typeof params.url === "string" && params.url.length > 0
|
||||
? params.url
|
||||
: config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl;
|
||||
@@ -607,29 +616,32 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
||||
});
|
||||
|
||||
ctx.actions.register("resolve-secret", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const secretRef = typeof params.secretRef === "string" && params.secretRef.length > 0
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
const secretRef = isSecretRefBinding(params.secretRef)
|
||||
? params.secretRef
|
||||
: config.secretRefExample || "";
|
||||
: isSecretRefBinding(config.secretRefExample)
|
||||
? config.secretRefExample
|
||||
: null;
|
||||
if (!secretRef) {
|
||||
throw new Error("No secret reference configured");
|
||||
}
|
||||
const resolved = await ctx.secrets.resolve(secretRef);
|
||||
const resolved = await ctx.secrets.resolve(secretRef, { companyId, configPath: "secretRefExample" });
|
||||
pushRecord({
|
||||
level: "info",
|
||||
source: "secrets",
|
||||
message: `Resolved secret reference ${secretRef}`,
|
||||
message: `Resolved secret reference ${secretRef.secretId}`,
|
||||
});
|
||||
return {
|
||||
secretRef,
|
||||
secretRef: secretRef.secretId,
|
||||
resolvedLength: resolved.length,
|
||||
preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "",
|
||||
};
|
||||
});
|
||||
|
||||
ctx.actions.register("run-process", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
||||
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
||||
const commandKey = typeof params.commandKey === "string" ? params.commandKey : "pwd";
|
||||
@@ -638,11 +650,11 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
||||
});
|
||||
|
||||
ctx.actions.register("read-workspace-file", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
if (!config.enableWorkspaceDemos) {
|
||||
throw new Error("Workspace demos are disabled in plugin settings");
|
||||
}
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
||||
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
||||
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
|
||||
@@ -660,11 +672,11 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
||||
});
|
||||
|
||||
ctx.actions.register("write-workspace-scratch", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
if (!config.enableWorkspaceDemos) {
|
||||
throw new Error("Workspace demos are disabled in plugin settings");
|
||||
}
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
||||
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
||||
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
|
||||
@@ -965,8 +977,7 @@ const plugin: PaperclipPlugin = definePlugin({
|
||||
},
|
||||
|
||||
async onHealth(): Promise<PluginHealthDiagnostics> {
|
||||
const ctx = currentContext;
|
||||
const config = ctx ? await getConfig(ctx) : DEFAULT_CONFIG;
|
||||
const config: KitchenSinkConfig = DEFAULT_CONFIG;
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Kitchen Sink plugin ready",
|
||||
|
||||
Reference in new issue
Block a user