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:
DottaandPaperclip authored and GitHub committed 2026-07-14 12:57:20 -05:00
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",