Compare commits

...

2 Commits

Author SHA1 Message Date
hongjr03 bb96159b3b feat(hub): expose pdf_to_md_bundle as MCP tool to agent (ADR-0027)
The Agent was falling back to Read+Write to manually parse PDFs because it
had no way to invoke the capability adapter. This adds a convert_pdf_to_md
MCP tool to the cph_hub server so the Agent can call it like send_file.

Changes:
- roleTools.ts: register convert_pdf_to_md as a CPH_HUB_MCP tool id
- fileDeliveryTool.ts: add convert_pdf_to_md tool that creates the
  PdfToMdBundleAdapter (with AliyunDocmindClient + org credential resolution)
  and invokes it with workspace-confined paths. Returns generated file paths
  + page count + cost. MCP instructions tell the Agent to always use this
  tool instead of Read/Bash for PDF parsing.
- trigger.ts: pass secretEnvelope + prisma + organizationId to the MCP
  server so it can resolve capability credentials
- hub.ts: pass secretEnvelope to makeTriggerHandler
- skills/pdf-to-md/SKILL.md: thin skill describing the workflow
  (download from Feishu → convert_pdf_to_md → send_file), when to use, and
  the fallback message if no capability connection is configured

The Agent now sees mcp__cph_hub__convert_pdf_to_md and the MCP instructions
explicitly say: 'Do NOT attempt to parse PDFs yourself with Read or Bash —
always use convert_pdf_to_md.'

Version bump 0.0.32 → 0.0.33.
2026-07-18 21:41:40 +08:00
hongjr03 ef96f8d33d feat(hub): capability connection admin API + UI + release v0.0.32 (#8)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:57:04 +08:00
13 changed files with 838 additions and 2 deletions
+25
View File
@@ -168,6 +168,16 @@ export interface FeishuApplicationConnection {
updatedAt: string;
}
export interface CapabilityConnection {
id: string;
capabilityId: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals {
runCount: number;
runsWithCost: number;
@@ -381,6 +391,21 @@ export const api = {
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capabilityConnections: (slug: string) =>
get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>,
capabilityConnection: (slug: string, capabilityId: string) =>
get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{
connection: CapabilityConnection | null;
}>,
rotateCapabilityConnection: (
slug: string,
capabilityId: string,
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) =>
del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<CapabilityConnection>,
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
+1
View File
@@ -28,6 +28,7 @@
{ key: 'skills', label: '技能', icon: 'roles' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
];
function isAdmin(org: OrgMembership): boolean {
@@ -0,0 +1,202 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const KNOWN_CAPABILITIES = [
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
let loading = $state(true);
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let accessKeyId = $state('');
let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let saving = $state(false);
let disabling = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const res = await api.capabilityConnections(slug);
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startEdit(capId: string) {
editingCap = capId;
accessKeyId = '';
accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
}
function cancelEdit() {
editingCap = null;
}
async function save(capId: string) {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
saving = true;
try {
const result = await api.rotateCapabilityConnection(slug, capId, {
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim(),
});
connections.set(capId, result);
connections = new Map(connections);
editingCap = null;
toastSuccess('能力凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable(capId: string) {
if (!confirm('停用后该能力将不可用,确定停用?')) return;
disabling = capId;
try {
const result = await api.disableCapabilityConnection(slug, capId);
connections.set(capId, result);
connections = new Map(connections);
toastSuccess('已停用能力连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = null;
}
}
function statusBadge(status: string): string {
if (status === 'ACTIVE') return 'saas-badge-primary';
if (status === 'DISABLED') return 'saas-badge-error';
return 'saas-badge-muted';
}
function statusLabel(status: string): string {
if (status === 'ACTIVE') return '已启用';
if (status === 'DISABLED') return '已停用';
return '草稿';
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="外部能力"
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="space-y-6">
{#each KNOWN_CAPABILITIES as cap}
{@const conn = connections.get(cap.id)}
<div class="saas-card-pad">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h3 class="saas-section-title">{cap.label}</h3>
{#if conn}
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
{:else}
<span class="saas-badge-muted">未配置</span>
{/if}
</div>
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
</div>
<div class="flex items-center gap-2">
{#if conn?.status === 'ACTIVE'}
<button
class="saas-btn-ghost text-sm"
onclick={() => disable(cap.id)}
disabled={disabling === cap.id}
>
{disabling === cap.id ? '停用中…' : '停用'}
</button>
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
</button>
</div>
</div>
{#if conn}
<dl class="space-y-1.5 text-sm text-surface-700">
<div class="flex justify-between">
<dt class="text-surface-500">版本</dt>
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">密钥 ID</dt>
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">更新时间</dt>
<dd>{fmtDate(conn.updatedAt)}</dd>
</div>
</dl>
{/if}
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} />
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.33",
"private": true,
"type": "module",
"engines": {
+63
View File
@@ -0,0 +1,63 @@
---
name: pdf-to-md
description: >
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
Handles PDFs from Feishu messages, local workspace files, and produces
high-quality Markdown with LaTeX formulas and extracted images.
---
# PDF to Markdown Conversion
## When to use
Use this skill when the user asks to convert a PDF to Markdown, extract text
from a PDF, or turn a PDF document into an editable format.
## How it works
The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls
Alibaba Cloud Document Mind to parse the PDF. It:
- Extracts text in reading order (handles multi-column, scanned, and
multi-language documents)
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
- Extracts tables as Markdown tables
- Downloads embedded images into the output directory
- Writes a single `document.md` file plus image files
## Workflow
### PDF from a Feishu message
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment.
2. Use `feishu_download_resource` to download it into the workspace.
3. Use `convert_pdf_to_md` with the downloaded file path and an output directory.
### PDF already in the workspace
1. Use `convert_pdf_to_md` directly with the file path and an output directory.
## Important rules
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
PDFs yourself with Read, Bash, Python, or any other method. The tool provides
accurate formula, table, and image extraction that manual methods cannot
match.
- If `convert_pdf_to_md` fails because no capability connection is configured,
tell the user to ask their organization admin to configure the Aliyun
docmind credential in the admin web UI (组织后台 → 能力).
- The output directory will be created if it does not exist.
- After conversion, use `send_file` to send the generated markdown back to the
user if they requested it.
## Output
The tool returns a list of generated files:
- `document.md` — the main markdown file
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
## Cost
The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the
enhanced formula mode). The cost is automatically recorded on the run's
usage ledger.
@@ -0,0 +1,131 @@
/**
* ADR-0027: Admin routes for organization-scoped capability connections.
* GET /api/org/:orgSlug/capability-connections — list all
* GET /api/org/:orgSlug/capability-connections/:capId — read one
* PUT /api/org/:orgSlug/capability-connections/:capId — rotate/create
* DELETE /api/org/:orgSlug/capability-connections/:capId — disable
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface CapabilityConnectionRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly readinessProbe?: CapabilityReadinessProbe;
}
export async function registerCapabilityConnectionRoutes(
app: FastifyInstance,
config: CapabilityConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connections: await connections.list(auth.organization.id) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.list" }, "list failed");
return handleRouteError(reply, error);
}
});
app.get("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connection: await connections.read(auth.organization.id, capabilityId) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.read" }, "read failed");
return handleRouteError(reply, error);
}
});
app.put("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const result = await connections.rotate({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
...body,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
secretVersion: result.activeVersion,
}, result.created ? "Capability Connection created" : "Capability Connection rotated");
const { created, ...metadata } = result;
return reply.status(created ? 201 : 200).send(metadata);
} catch (error) {
const facts = error instanceof CapabilityReadinessError
? {
errorCode: error.code,
failureCategory: error.category,
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
}
: { errorCode: "capability_connection_write_failed" };
request.log.error({ requestId: request.id, operation: "capability_connection.rotate", ...facts }, "rotate failed");
return handleRouteError(reply, error);
}
});
app.delete("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const result = await connections.disable({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
}, "Capability Connection disabled");
return reply.send(result);
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.disable" }, "disable failed");
return handleRouteError(reply, error);
}
});
}
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
throw new Error(`${name} is required`);
}
}
return {
accessKeyId: body["accessKeyId"] as string,
accessKeySecret: body["accessKeySecret"] as string,
endpoint: body["endpoint"] as string,
};
}
+11
View File
@@ -22,6 +22,8 @@ import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
import { registerCapabilityConnectionRoutes } from "./capabilityConnectionRoutes.js";
import type { CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
export interface OrgRouteConfig {
readonly prisma: PrismaClient;
@@ -30,6 +32,7 @@ export interface OrgRouteConfig {
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
readonly capabilityReadinessProbe?: CapabilityReadinessProbe;
}
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
@@ -138,4 +141,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
? { readinessProbe: config.feishuConnectionReadinessProbe }
: {}),
});
await registerCapabilityConnectionRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
secretEnvelope: config.secretEnvelope,
...(config.capabilityReadinessProbe !== undefined
? { readinessProbe: config.capabilityReadinessProbe }
: {}),
});
}
+3
View File
@@ -14,6 +14,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_read_context",
"feishu_download_resource",
"request_approval",
"convert_pdf_to_md",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -50,10 +51,12 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["feishu_read_context", "feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"],
["request_approval", "request_approval"],
["convert_pdf_to_md", "convert_pdf_to_md"],
["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
["mcp__cph_hub__request_approval", "request_approval"],
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"],
]);
const SUPPORTED_ROLE_TOOLS = new Set([
@@ -0,0 +1,266 @@
/**
* ADR-0027: Organization-scoped capability connection service. Manages the
* lifecycle (rotate / read / disable) of capability credentials stored in
* ADR-0024 encrypted envelopes with purpose="capability".
*
* Mirrors FeishuApplicationConnectionService, but keyed by (organizationId,
* capabilityId) instead of 1:1 — an org may have multiple capabilities.
*/
import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import type { CapabilitySecretPayload } from "./types.js";
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
export interface CapabilityCredentialInput {
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
export interface RotateCapabilityInput extends CapabilityCredentialInput {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}
export interface CapabilityConnectionMetadata {
readonly id: string;
readonly capabilityId: string;
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
readonly activeVersion: number | null;
readonly keyId: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata {
readonly created: boolean;
}
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService {
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
) {}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
}
const payload = validateCredential(input);
await this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
});
await this.readinessProbe({
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.upsert({
where: {
organizationId_capabilityId: {
organizationId: input.organizationId,
capabilityId: input.capabilityId,
},
},
update: {},
create: {
id: randomUUID(),
organizationId: input.organizationId,
capabilityId: input.capabilityId,
status: "DRAFT",
},
});
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: {
activeSecretVersion: true,
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
},
});
if (locked.organizationId !== input.organizationId) {
throw new Error("Capability Connection scope changed during rotation");
}
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
const secretVersionId = randomUUID();
const envelope = this.secrets.encryptJson(
{
purpose: "capability",
organizationId: input.organizationId,
connectionId: locked.id,
secretVersionId,
},
payload,
);
const now = new Date();
const secretVersion = await tx.capabilityCredentialVersion.create({
data: {
id: secretVersionId,
connectionId: locked.id,
version,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
envelope: envelope as unknown as Prisma.InputJsonValue,
createdByUserId: input.actorUserId,
},
});
if (locked.activeSecretVersion !== null) {
await tx.capabilityCredentialVersion.update({
where: { id: locked.activeSecretVersion.id },
data: { retiredAt: now },
});
}
const activated = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: {
status: "ACTIVE",
activeSecretVersionId: secretVersion.id,
activatedAt: now,
disabledAt: null,
},
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: version === 1 ? "capability.created" : "capability.rotated",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
status: "ACTIVE",
secretVersion: version,
keyId: envelope.keyId,
},
},
});
return {
...toMetadata(activated, { version, keyId: secretVersion.keyId }),
created: version === 1,
};
});
}
async list(organizationId: string): Promise<CapabilityConnectionMetadata[]> {
const connections = await this.prisma.organizationCapabilityConnection.findMany({
where: { organizationId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
orderBy: { capabilityId: "asc" },
});
return connections.map((c) => toMetadata(c, c.activeSecretVersion));
}
async read(organizationId: string, capabilityId: string): Promise<CapabilityConnectionMetadata | null> {
const connection = await this.prisma.organizationCapabilityConnection.findFirst({
where: { organizationId, capabilityId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
return connection === null ? null : toMetadata(connection, connection.activeSecretVersion);
}
async disable(input: {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}): Promise<CapabilityConnectionMetadata> {
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.findFirst({
where: { organizationId: input.organizationId, capabilityId: input.capabilityId },
select: { id: true },
});
if (connection === null) throw new Error("Capability Connection not found");
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion);
const disabled = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: { status: "DISABLED", disabledAt: new Date() },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: "capability.disabled",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
previousStatus: locked.status,
status: "DISABLED",
},
},
});
return toMetadata(disabled, locked.activeSecretVersion);
});
}
}
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
}
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
const endpoint = nonEmpty(input.endpoint, "endpoint");
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
}
function toMetadata(
connection: {
readonly id: string;
readonly capabilityId: string;
readonly status: string;
readonly createdAt: Date;
readonly updatedAt: Date;
},
secret: { readonly version: number; readonly keyId: string } | null,
): CapabilityConnectionMetadata {
return {
id: connection.id,
capabilityId: connection.capabilityId,
status: connection.status as CapabilityConnectionMetadata["status"],
activeVersion: secret?.version ?? null,
keyId: secret?.keyId ?? null,
createdAt: connection.createdAt,
updatedAt: connection.updatedAt,
};
}
async function requireCapabilityAdmin(
tx: Prisma.TransactionClient,
input: { readonly organizationId: string; readonly actorUserId: string },
): Promise<void> {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.actorUserId,
role: { in: ["OWNER", "ADMIN"] },
revokedAt: null,
},
select: { id: true },
});
if (membership === null) {
throw new Error("only Organization OWNER or ADMIN may manage capability connections");
}
}
function nonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad
* request) means the credential is valid (the API accepted auth but rejected
* the id); a 401/403 means the credential is bad.
*/
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
export interface CapabilityReadinessInput {
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export class CapabilityReadinessError extends Error {
constructor(
readonly code: "capability_readiness_unsupported" | "capability_readiness_unreachable" | "capability_readiness_rejected",
message: string,
readonly category: NetworkFailureCategory | "configuration" | "http",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "CapabilityReadinessError";
}
}
/**
* Probe the Alibaba Cloud docmind credential. We call QueryDocParserStatus
* with a dummy id. The API will return:
* - 400 (InvalidParameter) → credential valid, just a bad id → probe passes
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
* - network error → unreachable
*/
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: { authorization: authHeader, accept: "application/json" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
throw new CapabilityReadinessError(
"capability_readiness_unreachable",
"docmind credential readiness check could not reach the API",
classifyNetworkFailure(error),
);
}
await response.body?.cancel();
if (response.status === 401 || response.status === 403) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`docmind credential rejected: status ${response.status}`,
"http",
response.status,
);
}
};
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString("base64");
return `Basic ${credentials}`;
}
+59
View File
@@ -7,11 +7,16 @@ import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly projectId: string;
readonly organizationId: string;
readonly runId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string;
@@ -20,6 +25,8 @@ export interface FileDeliveryToolOptions {
readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void;
readonly tools?: readonly CphHubMcpToolId[] | undefined;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
@@ -230,6 +237,51 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
);
}
if (enabledTools.has("convert_pdf_to_md")) {
const adapter = createPdfToMdBundleAdapter({
secrets: options.secretEnvelope,
client: new AliyunDocmindClient(),
prisma: options.prisma,
});
tools.push(
tool(
"convert_pdf_to_md",
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
{
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
},
async (args) => {
try {
const result = await adapter.invoke({
runId: options.runId,
organizationId: options.organizationId,
projectId: options.projectId,
workspaceDir: options.workspaceDir,
inputPath: args.input_path,
outputDir: args.output_dir,
prisma: options.prisma,
});
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
for (const artifact of result.artifacts) {
lines.push(` - ${artifact.path} (${artifact.kind})`);
}
lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
};
} catch (e) {
return {
isError: true,
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
};
}
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
name: "cph_hub",
@@ -260,5 +312,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
}
if (enabledTools.has("convert_pdf_to_md")) {
instructions.push(
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
);
}
return instructions.join(" ");
}
+6 -1
View File
@@ -14,6 +14,7 @@ import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
sendText,
sendTextMessage,
@@ -93,6 +94,7 @@ interface TriggerDeps {
readonly prisma: PrismaClient;
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly secretEnvelope: LocalSecretEnvelope;
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
@@ -486,6 +488,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Streaming agent card: single interactive card through the full run
// lifecycle (thinking → tool calls → streaming text → complete).
// Shows tool-use trace panel + reasoning panel + answer text.
const deliveredFiles: string[] = [];
const card = new StreamingAgentCard({
runId: run.id,
rt,
@@ -494,11 +497,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
patchIntervalMs: undefined,
maxMessageLength: undefined,
});
const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt,
chatId,
projectId,
organizationId: siloOrganizationId,
runId: run.id,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
@@ -506,6 +509,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions,
approvalManager,
tools: cphHubMcpToolsForRole(roleTools),
prisma: deps.prisma,
secretEnvelope: deps.secretEnvelope,
onDelivered: (path) => {
deliveredFiles.push(path);
},
+1
View File
@@ -162,6 +162,7 @@ export async function startHub(): Promise<void> {
prisma,
settings: runtimeSettings,
logger: app.log,
secretEnvelope,
projectWorkspaceRoot,
publicBaseUrl,
siloOrganizationId: siloOrganization.id,