feat(manager): /sk-sources now uses mcp-server P1 ResourceBinding (§2)

docs/Heicode-对接进度与待办.md §7.5 point 7 named the Manager team as
responsible for wiring P1 resource UI to mcp-server (§2 ResourceBinding,
§3 ResourceGrant) — without this, the deeplink that the desktop client
puts on the task card (`/manager/resources?from=task`) ends in a 404.

Changes:

1. lib/heicode-mcp.ts: typed wrappers for the 9 P1 endpoints
   - §2 ResourceBinding: list / get / create / update / revoke
   - §3 ResourceGrant: list / get / create / revoke
   - Field shape verified against live mcp-server with test account
     55@55.com — 7 smoke cases pass including the 422 sensitive-keyword
     enforcement and the §3 subset rule.

2. features/agnet-console/pages.tsx AgnetSKSourcesPage rewritten to
   read /api/resources (filtered to status=active) instead of the
   legacy Heicode-local git_sources controller:
   - Card 1 代码        = resources filter type='git'
   - Card 2 文档SK      = resources filter type∈{sk,project_doc}
   - Card 3 云账号      = resources filter type∈{cloud_account,
                          cloud_resource}, "auto-discovery coming soon"
                          hint shown when empty (current state)
   - Card 4 推荐摘要    = unchanged

3. Advanced sheet form rewritten for mcp-server ResourceBinding shape:
   {type, name, external_ref, metadata, permission_scope, constraints,
    secret_ref, status}. Old (provider, repo_url, ref, paths, usage,
    tenant_id) maps in:
     name          → name
     repo_url      → external_ref
     provider      → metadata.provider
     ref           → metadata.default_branch + constraints.ref
     paths         → constraints.allowed_paths (comma-joined)
     usage         → type ('git'/'sk'/'project_doc')
     tenant_id     → dropped (server uses auth.user_id)
     —             → permission_scope ['repo:read'] minimal default
     —             → secret_ref blank for now (server fills once
                     OpenBao Secret Broker lands per §2.1 TODO)

   Form also surfaces the §2.1 422 RESOURCE_GRANT_SECRET_REJECTED
   server-side error to the user.

4. Removed unused imports (GitSource{,Payload,Usage}, createGitSource,
   deleteGitSource, listGitSources) — legacy git_sources controller is
   still in the Go backend for now but the Manager no longer consumes it.

5. RecommendationSummaryDialog now takes ResourceBinding[] for project /
   sk source counters instead of GitSource[].

Smoke verified end-to-end against live mcp-server:
  list / create (incl. metadata+constraints+permission_scope) / get /
  delete-binding all 200 with expected shapes; 422 secret rejection
  fires on metadata.{name containing 'token'}; §3 subset rule on
  allowed_actions outside binding.permission_scope returns
  RESOURCE_GRANT_INVALID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:33:18 +08:00
co-authored by Claude Opus 4.7
parent ab53f34334
commit 4c263040c9
4 changed files with 427 additions and 139 deletions
+187 -109
View File
@@ -41,23 +41,26 @@ import {
import {
getAgnetDeploymentEvents,
getAgnetSnapshots,
createGitSource,
deleteGitSource,
listGitSources,
listAgnetDeployments,
listAgnetDeploymentsQuiet,
type AgnetDeployment,
type AgnetRuntimeExecution,
type AgnetSKAccessPolicy,
type GitSource,
type GitSourcePayload,
type GitSourceUsage,
} from './api'
// /audit pulls from mcp-server §5.10 stub now, not the Heicode-local
// controller — the contract doc names that endpoint as the canonical
// source. The shape of McpAuditEntry is wider than the legacy local one
// so the redacted-card renderer keeps working.
import { listMcpAuditLogs } from '@/lib/heicode-mcp'
// /sk-sources resource list/create/revoke moved to mcp-server §2 P1
// ResourceBinding (commit ?).
import {
createResource,
listMcpAuditLogs,
listResources,
revokeResource,
type ResourceBinding,
type ResourceType,
} from '@/lib/heicode-mcp'
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
import { toast } from 'sonner'
@@ -904,65 +907,104 @@ export function AgnetAuditPage() {
// + /11 §5. Does not expose repo_url / ref / paths / usage / tenant_id as the
// main flow — those move into a “手动补充”次级 sheet only opened when the user
// clicks “连接代码仓库 → 高级补充”.
// AgnetSKSourcesPage — 准备清单 wizard.
//
// Data layer switched (commit ?) from the Heicode-local git_sources controller
// to mcp-server §2 ResourceBinding (/api/resources) per the contract docs
// §2/§3 and Heicode-对接进度与待办.md §7.5 point 7 ("Manager 团队补上 —
// 否则用户从客户端任务卡点 '去 Manager 准备' 按钮过去后会 404").
//
// Field mapping for the advanced "manual entry" sheet:
// old GitSourcePayload → mcp-server ResourceBinding
// name → name
// repo_url → external_ref
// provider → metadata.provider
// ref → metadata.default_branch + constraints.ref
// paths (string[]) → constraints.allowed_paths (comma-joined)
// usage ('project'|'sk'|...) → type ('git' for project, 'sk' for SK,
// 'project_doc' for docs)
// tenant_id → (dropped — server uses auth.user_id)
// (none) → permission_scope ['repo:read']
// (none) → secret_ref (left blank for now; server
// will write vault://... once Secret
// Broker lands per §2.1 TODO)
export function AgnetSKSourcesPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [advancedOpen, setAdvancedOpen] = useState(false)
const [gitForm, setGitForm] = useState<GitSourcePayload>({
const [resourceForm, setResourceForm] = useState<{
name: string
type: ResourceType
external_ref: string
provider: string
ref: string
allowed_paths: string
}>({
name: '',
type: 'git',
external_ref: '',
provider: 'github',
repo_url: '',
ref: 'main',
paths: ['.'],
usage: 'project',
tenant_id: '',
allowed_paths: '.',
})
const [pathsText, setPathsText] = useState('.')
const gitSourcesQuery = useQuery({
queryKey: ['git-sources'],
queryFn: listGitSources,
const resourcesQuery = useQuery({
queryKey: ['heicode', 'resources'],
queryFn: () =>
listResources({ status: 'active', limit: 200 }).then((r) => r.items),
// Empty list when mcp-server unreachable is OK — UI degrades gracefully.
retry: false,
})
const gitSources = gitSourcesQuery.data ?? []
const resources = resourcesQuery.data ?? []
const createGitMutation = useMutation({
mutationFn: () =>
createGitSource({
...gitForm,
paths: pathsText
.split('\n')
const createResourceMutation = useMutation({
mutationFn: () => {
const isGit = resourceForm.type === 'git'
return createResource({
type: resourceForm.type,
name: resourceForm.name.trim(),
external_ref: resourceForm.external_ref.trim() || undefined,
metadata: isGit
? {
provider: resourceForm.provider,
default_branch: resourceForm.ref,
}
: {},
permission_scope: isGit ? ['repo:read'] : [],
constraints: isGit
? {
ref: resourceForm.ref,
allowed_paths: resourceForm.allowed_paths
.split(/[\n,]/)
.map((x) => x.trim())
.filter(Boolean),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['git-sources'] })
setGitForm({
name: '',
provider: 'github',
repo_url: '',
ref: 'main',
paths: ['.'],
usage: 'project',
tenant_id: '',
.filter(Boolean)
.join(','),
}
: {},
status: 'active',
})
setPathsText('.')
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['heicode', 'resources'] })
setResourceForm((prev) => ({ ...prev, name: '', external_ref: '' }))
},
})
const deleteGitMutation = useMutation({
mutationFn: deleteGitSource,
const revokeResourceMutation = useMutation({
mutationFn: (id: string) => revokeResource(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['git-sources'] })
void queryClient.invalidateQueries({ queryKey: ['heicode', 'resources'] })
},
})
const [summaryOpen, setSummaryOpen] = useState(false)
const projectSources = gitSources.filter(
(s) => s.usage === 'project' || s.usage === 'combined'
const projectSources = resources.filter((r) => r.type === 'git')
const skSources = resources.filter(
(r) => r.type === 'sk' || r.type === 'project_doc'
)
const skSources = gitSources.filter(
(s) => s.usage === 'sk' || s.usage === 'combined'
const cloudSources = resources.filter(
(r) => r.type === 'cloud_account' || r.type === 'cloud_resource'
)
const steps = [
@@ -988,11 +1030,15 @@ export function AgnetSKSourcesPage() {
{
key: 'cloud',
title: t('Connect cloud account'),
summary: t(
'Authorize Azure / AWS / GCP. Heicode auto-discovers resources.'
),
done: false,
pendingHint: t('Cloud auto-discovery — coming soon'),
summary:
cloudSources.length > 0
? t('{{n}} cloud resource connected', { n: cloudSources.length })
: t('Authorize Azure / AWS / GCP. Heicode auto-discovers resources.'),
done: cloudSources.length > 0,
pendingHint:
cloudSources.length === 0
? t('Cloud auto-discovery — coming soon')
: undefined,
},
{
key: 'review',
@@ -1135,22 +1181,51 @@ export function AgnetSKSourcesPage() {
{t('Source name')}
</label>
<Input
value={gitForm.name}
value={resourceForm.name}
onChange={(e) =>
setGitForm((v) => ({ ...v, name: e.target.value }))
setResourceForm((v) => ({ ...v, name: e.target.value }))
}
placeholder='project-main'
className='mt-1 h-9 text-xs'
/>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Resource type')}
</label>
<Select
value={resourceForm.type}
onValueChange={(type) =>
setResourceForm((v) => ({
...v,
type: type as ResourceType,
}))
}
>
<SelectTrigger className='mt-1 h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='git'>{t('Code (Git)')}</SelectItem>
<SelectItem value='sk'>{t('SK repository')}</SelectItem>
<SelectItem value='project_doc'>
{t('Project docs')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{resourceForm.type === 'git' && (
<div className='grid gap-2 sm:grid-cols-2'>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Provider')}
</label>
<Select
value={gitForm.provider}
value={resourceForm.provider}
onValueChange={(provider) =>
setGitForm((v) => ({ ...v, provider }))
setResourceForm((v) => ({ ...v, provider }))
}
>
<SelectTrigger className='mt-1 h-9 text-xs'>
@@ -1165,86 +1240,84 @@ export function AgnetSKSourcesPage() {
</SelectContent>
</Select>
</div>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Repository URL')}
</label>
<Input
value={gitForm.repo_url}
onChange={(e) =>
setGitForm((v) => ({ ...v, repo_url: e.target.value }))
}
placeholder='https://github.com/org/repo.git'
className='mt-1 h-9 font-mono text-xs'
/>
</div>
<div className='grid gap-2 sm:grid-cols-2'>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Branch')}
</label>
<Input
value={gitForm.ref}
value={resourceForm.ref}
onChange={(e) =>
setGitForm((v) => ({ ...v, ref: e.target.value }))
setResourceForm((v) => ({ ...v, ref: e.target.value }))
}
className='mt-1 h-9 font-mono text-xs'
/>
</div>
</div>
)}
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Usage')}
{resourceForm.type === 'git'
? t('Repository URL')
: t('External reference (URL or doc location)')}
</label>
<Select
value={gitForm.usage}
onValueChange={(usage) =>
setGitForm((v) => ({
<Input
value={resourceForm.external_ref}
onChange={(e) =>
setResourceForm((v) => ({
...v,
usage: usage as GitSourceUsage,
external_ref: e.target.value,
}))
}
>
<SelectTrigger className='mt-1 h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='project'>
{t('Project repository')}
</SelectItem>
<SelectItem value='sk'>{t('SK repository')}</SelectItem>
<SelectItem value='combined'>
{t('Combined repository')}
</SelectItem>
</SelectContent>
</Select>
</div>
placeholder={
resourceForm.type === 'git'
? 'https://github.com/org/repo.git'
: 'https://example.com/docs'
}
className='mt-1 h-9 font-mono text-xs'
/>
</div>
{resourceForm.type === 'git' && (
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Allowed paths')}
</label>
<textarea
value={pathsText}
onChange={(e) => setPathsText(e.target.value)}
value={resourceForm.allowed_paths}
onChange={(e) =>
setResourceForm((v) => ({
...v,
allowed_paths: e.target.value,
}))
}
rows={2}
spellCheck={false}
className='mt-1 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring'
/>
</div>
)}
<p className='rounded-lg border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/40 p-3 text-[11px] text-muted-foreground'>
{t(
'Plaintext credentials are never accepted. Heicode writes the real token to the secret vault and stores only secret_ref.'
)}
</p>
{createResourceMutation.isError && (
<p className='rounded-lg border border-rose-500/30 bg-rose-500/10 p-2 text-xs text-rose-300'>
{(createResourceMutation.error as Error)?.message}
</p>
)}
<Button
type='button'
className='w-fit gap-1.5'
disabled={
createGitMutation.isPending ||
!gitForm.name.trim() ||
!gitForm.repo_url.trim()
createResourceMutation.isPending ||
!resourceForm.name.trim()
}
onClick={() => createGitMutation.mutate()}
onClick={() => createResourceMutation.mutate()}
>
<Plus className='h-3.5 w-3.5' />
{t('Bind source')}
@@ -1252,40 +1325,45 @@ export function AgnetSKSourcesPage() {
</div>
<div className='mt-5 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-4'>
<p className='text-sm font-medium'>
{t('Connected sources')}
</p>
{gitSourcesQuery.isLoading ? (
<p className='text-sm font-medium'>{t('Connected sources')}</p>
{resourcesQuery.isLoading ? (
<div className='mt-2 space-y-2'>
<Skeleton className='h-12 rounded-lg' />
<Skeleton className='h-12 rounded-lg' />
</div>
) : gitSources.length === 0 ? (
) : resources.length === 0 ? (
<p className='mt-2 text-xs text-muted-foreground'>
{t('No sources connected yet.')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{gitSources.map((src) => (
{resources.map((src: ResourceBinding) => (
<li
key={src.id}
className='flex items-start justify-between gap-3 rounded-lg border border-border bg-background/60 p-3'
>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<p className='truncate text-sm font-medium'>
{src.name}
</p>
<span className='rounded-full bg-[color-mix(in_oklch,var(--primary)_14%,transparent)] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-primary'>
{src.type}
</span>
</div>
{src.external_ref && (
<p className='mt-0.5 truncate font-mono text-[11px] text-muted-foreground'>
{src.repo_url}
{src.external_ref}
</p>
)}
</div>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 shrink-0'
disabled={deleteGitMutation.isPending}
onClick={() => deleteGitMutation.mutate(src.id)}
disabled={revokeResourceMutation.isPending}
onClick={() => revokeResourceMutation.mutate(src.id)}
>
<Trash2 className='h-3.5 w-3.5 text-destructive' />
</Button>
@@ -1322,8 +1400,8 @@ function RecommendationSummaryDialog({
skSources,
}: {
onClose: () => void
projectSources: GitSource[]
skSources: GitSource[]
projectSources: ResourceBinding[]
skSources: ResourceBinding[]
}) {
const { t } = useTranslation()
const [launching, setLaunching] = useState(false)
+6
View File
@@ -677,6 +677,7 @@
"CNY": "CNY",
"CNY per USD": "CNY per USD",
"Code": "Code",
"Code (Git)": "Code (Git)",
"Code delivery": "Code delivery",
"Code, docs, cloud resources and high-risk approval rules. Connect them in the preparation checklist; Heicode will auto-discover what it can and only ask for the rest.": "Code, docs, cloud resources and high-risk approval rules. Connect them in the preparation checklist; Heicode will auto-discover what it can and only ask for the rest.",
"Code, review, test in a sandboxed environment": "Code, review, test in a sandboxed environment",
@@ -1406,6 +1407,7 @@
"External link for users to purchase quota": "External link for users to purchase quota",
"External operations": "External operations",
"External operations mode": "External operations mode",
"External reference (URL or doc location)": "External reference (URL or doc location)",
"External Speed Test": "External Speed Test",
"Extra": "Extra",
"Extra Notes (Optional)": "Extra Notes (Optional)",
@@ -2632,6 +2634,7 @@
"Pick a date": "Pick a date",
"Pick an existing SK / docs repository or skip": "Pick an existing SK / docs repository or skip",
"Ping Interval (seconds)": "Ping Interval (seconds)",
"Plaintext credentials are never accepted. Heicode writes the real token to the secret vault and stores only secret_ref.": "Plaintext credentials are never accepted. Heicode writes the real token to the secret vault and stores only secret_ref.",
"Plan": "Plan",
"Plan Name": "Plan Name",
"Plan Subtitle": "Plan Subtitle",
@@ -2774,6 +2777,7 @@
"Profile updated successfully": "Profile updated successfully",
"Progress": "Progress",
"Project": "Project",
"Project docs": "Project docs",
"Project repository": "Project repository",
"Promote": "Promote",
"Promotion codes": "Promotion codes",
@@ -3019,6 +3023,7 @@
"Resource permission scope is required": "Resource permission scope is required",
"Resource scope": "Resource scope",
"Resource scope ref": "Resource scope ref",
"Resource type": "Resource type",
"Resources": "Resources",
"Resources binding explainer": "Resources are the user-visible bindings behind runs: code repos, SK repos, project documents, cloud accounts, and cloud resources. This page still uses the Git-source API slice, but the operating model is Resource Binding plus Resource Grant: metadata, scope, status, and secret_ref only.",
"Resources subtitle": "Bind resource sources, then inspect the snapshot anchors each Work/Run resolved for manifest and audit replay.",
@@ -4031,6 +4036,7 @@
"{{done}}/{{total}} completed": "{{done}}/{{total}} completed",
"{{field}} updated to {{value}}": "{{field}} updated to {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} updated to {{value}} for tag: {{tag}}",
"{{n}} cloud resource connected": "{{n}} cloud resource connected",
"{{n}} model(s) selected": "{{n}} model(s) selected",
"{{n}} repository connected": "{{n}} repository connected",
"{{n}} source connected": "{{n}} source connected",
+6
View File
@@ -677,6 +677,7 @@
"CNY": "元",
"CNY per USD": "人民币兑美元汇率",
"Code": "代码",
"Code (Git)": "代码(Git)",
"Code delivery": "Code 交付",
"Code, docs, cloud resources and high-risk approval rules. Connect them in the preparation checklist; Heicode will auto-discover what it can and only ask for the rest.": "代码、文档、云资源以及高危审批规则。在准备清单里连接它们,Heicode 会自动发现可获取的部分,剩下的才请你补充。",
"Code, review, test in a sandboxed environment": "在沙箱环境内开发、审查、测试",
@@ -1406,6 +1407,7 @@
"External link for users to purchase quota": "供用户购买配额的外部链接",
"External operations": "对外运营",
"External operations mode": "对外运营模式",
"External reference (URL or doc location)": "外部引用(URL 或文档位置)",
"External Speed Test": "外部速度测试",
"Extra": "额外",
"Extra Notes (Optional)": "额外备注(可选)",
@@ -2632,6 +2634,7 @@
"Pick a date": "选择日期",
"Pick an existing SK / docs repository or skip": "选择已有 SK / 文档仓库,或跳过",
"Ping Interval (seconds)": "Ping 间隔(秒)",
"Plaintext credentials are never accepted. Heicode writes the real token to the secret vault and stores only secret_ref.": "永不接受明文凭证。Heicode 把真实 token 写入密钥保管器,只在数据库保存 secret_ref。",
"Plan": "套餐",
"Plan Name": "套餐名称",
"Plan Subtitle": "套餐副标题",
@@ -2774,6 +2777,7 @@
"Profile updated successfully": "个人资料更新成功",
"Progress": "进度",
"Project": "项目",
"Project docs": "项目文档",
"Project repository": "项目仓库",
"Promote": "提升",
"Promotion codes": "促销代码",
@@ -3019,6 +3023,7 @@
"Resource permission scope is required": "资源权限范围为必填项",
"Resource scope": "资源范围",
"Resource scope ref": "资源作用域引用",
"Resource type": "资源类型",
"Resources": "资源",
"Resources binding explainer": "Resources 是运行背后的用户可见绑定:代码仓库、SK 仓库、项目文档、云账号和云资源。本页仍复用 Git source API 切片,但运行模型是 Resource Binding 加 Resource Grant:只包含 metadata、scope、status 和 secret_ref。",
"Resources subtitle": "绑定资源来源,并查看每次 Work/Run 为 manifest 与审计回放解析出的快照锚点。",
@@ -4031,6 +4036,7 @@
"{{done}}/{{total}} completed": "{{done}}/{{total}} 已完成",
"{{field}} updated to {{value}}": "{{field}} 已更新为 {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "标签「{{tag}}」的 {{field}} 已更新为 {{value}}",
"{{n}} cloud resource connected": "已连接 {{n}} 个云资源",
"{{n}} model(s) selected": "已选 {{n}} 个模型",
"{{n}} repository connected": "已连接 {{n}} 个仓库",
"{{n}} source connected": "已连接 {{n}} 个来源",
+198
View File
@@ -316,6 +316,204 @@ export type McpAuditEntry = {
[key: string]: unknown
}
// =============================================================================
// §2 ResourceBinding — 5 endpoints
// Field shape per contract §2.1; type/status enums per §2.0.
// =============================================================================
export type ResourceType =
| 'git'
| 'sk'
| 'project_doc'
| 'cloud_account'
| 'cloud_resource'
export type ResourceStatus = 'pending' | 'active' | 'disabled' | 'revoked'
export type ResourceBinding = {
id: string
user_id: string
type: ResourceType
name: string
external_ref?: string
metadata?: Record<string, unknown>
permission_scope?: string[]
constraints?: Record<string, unknown>
secret_ref?: string
status: ResourceStatus
created_by?: string
updated_by?: string
created_at?: string
updated_at?: string
}
export type CreateResourceBody = {
type: ResourceType
name: string
external_ref?: string
metadata?: Record<string, unknown>
permission_scope?: string[]
constraints?: Record<string, unknown>
secret_ref?: string
status?: ResourceStatus
}
export async function listResources(params?: {
type?: ResourceType
status?: ResourceStatus
limit?: number
offset?: number
}): Promise<{ items: ResourceBinding[]; total: number; offset: number; limit: number }> {
const qs = new URLSearchParams()
if (params?.type) qs.set('type', params.type)
if (params?.status) qs.set('status', params.status)
if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
const env = await mcpFetch<Envelope<{ items: ResourceBinding[]; total: number; offset: number; limit: number }>>(
`/api/resources${suffix}`
)
return env.data ?? { items: [], total: 0, offset: 0, limit: 100 }
}
export async function getResource(id: string): Promise<ResourceBinding | null> {
try {
const env = await mcpFetch<Envelope<ResourceBinding>>(`/api/resources/${encodeURIComponent(id)}`)
return env.data ?? null
} catch (e) {
if (e instanceof Error && /404|NOT_FOUND/i.test(e.message)) return null
throw e
}
}
export async function createResource(body: CreateResourceBody): Promise<ResourceBinding> {
const env = await mcpFetch<Envelope<ResourceBinding>>('/api/resources', {
method: 'POST',
body: JSON.stringify(body),
})
if (!env.success || !env.data) {
throw new Error(env.message || 'createResource failed')
}
return env.data
}
export async function updateResource(
id: string,
body: Partial<CreateResourceBody>
): Promise<ResourceBinding> {
const env = await mcpFetch<Envelope<ResourceBinding>>(
`/api/resources/${encodeURIComponent(id)}`,
{
method: 'PUT',
body: JSON.stringify(body),
}
)
if (!env.success || !env.data) {
throw new Error(env.message || 'updateResource failed')
}
return env.data
}
export async function revokeResource(id: string): Promise<{ id: string; status: ResourceStatus }> {
const env = await mcpFetch<Envelope<{ id: string; status: ResourceStatus }>>(
`/api/resources/${encodeURIComponent(id)}`,
{ method: 'DELETE' }
)
if (!env.success || !env.data) {
throw new Error(env.message || 'revokeResource failed')
}
return env.data
}
// =============================================================================
// §3 ResourceGrant — 4 endpoints
// Field shape per contract §3.1; status enums per §3.0.
// =============================================================================
export type GrantStatus = 'active' | 'suspended' | 'revoked' | 'expired'
export type ResourceGrant = {
id: string
user_id: string
binding_scope: string
resource_id: string
role?: string
agent_id?: string | null
allowed_actions?: string[]
constraints?: Record<string, unknown>
status: GrantStatus
expires_at?: string | null
created_by?: string
revoked_by?: string | null
created_at?: string
revoked_at?: string | null
}
export type CreateGrantBody = {
resource_id: string
binding_scope: string
role?: string
agent_id?: string | null
allowed_actions?: string[]
constraints?: Record<string, unknown>
expires_at?: string
status?: GrantStatus
}
export async function listResourceGrants(params?: {
resource_id?: string
status?: GrantStatus
limit?: number
offset?: number
}): Promise<{ items: ResourceGrant[]; total: number; offset: number; limit: number }> {
const qs = new URLSearchParams()
if (params?.resource_id) qs.set('resource_id', params.resource_id)
if (params?.status) qs.set('status', params.status)
if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
const env = await mcpFetch<Envelope<{ items: ResourceGrant[]; total: number; offset: number; limit: number }>>(
`/api/resource-grants${suffix}`
)
return env.data ?? { items: [], total: 0, offset: 0, limit: 100 }
}
export async function getResourceGrant(id: string): Promise<ResourceGrant | null> {
try {
const env = await mcpFetch<Envelope<ResourceGrant>>(`/api/resource-grants/${encodeURIComponent(id)}`)
return env.data ?? null
} catch (e) {
if (e instanceof Error && /404|NOT_FOUND/i.test(e.message)) return null
throw e
}
}
export async function createResourceGrant(body: CreateGrantBody): Promise<ResourceGrant> {
const env = await mcpFetch<Envelope<ResourceGrant>>('/api/resource-grants', {
method: 'POST',
body: JSON.stringify(body),
})
if (!env.success || !env.data) {
throw new Error(env.message || 'createResourceGrant failed')
}
return env.data
}
export async function revokeResourceGrant(id: string): Promise<ResourceGrant | { id: string; status: GrantStatus }> {
const env = await mcpFetch<Envelope<ResourceGrant>>(
`/api/resource-grants/${encodeURIComponent(id)}`,
{ method: 'DELETE' }
)
if (!env.success || !env.data) {
throw new Error(env.message || 'revokeResourceGrant failed')
}
return env.data
}
// =============================================================================
// §5.10 Audit — kept here for symmetry
// =============================================================================
export async function listMcpAuditLogs(params?: {
binding_scope?: string
actor?: string