Files
goodbuddy/src/shared/capability-contracts.ts
T
mesalogo 55efca3ee1 feat: connect local Obsidian vaults to conversations
Users can now read, search, and update existing local Obsidian notes through
built-in tools without configuring an external MCP server or Obsidian plugin.
Add vault discovery, an optional folder scope, and independent test, save,
enablement, and runtime assignment controls in MCP settings.

Bundle MCPVault 0.16.0 and its runtime dependencies, exposing 19 shared tools
to direct models and managed local OpenCode and Continue conversations.
Obsidian is disabled by default. Ask remains read-only; Execute permits
writes. Scope and assignment changes revoke existing Obsidian grants.
DeepSeek Harness and remote execution remain unsupported.

Validate the isolated staged snapshot with its own dependency installation:
typecheck, lint, production build, and 540 tests across 14 files passed.
No paid model calls were repeated. Physical-offline, installer, and other
platform acceptance remain pending as documented in the feature record.

Release note: Connect local Obsidian vaults in MCP settings to find notes and
write conversation results back to them. Test all registered vaults or a
chosen folder before enabling access; no Obsidian plugin is required.
2026-09-27 00:37:46 +08:00

486 lines
13 KiB
TypeScript

import { z } from 'zod'
import { settingsWarningsSchema } from './settings-warning-contracts'
const controlCharacterFreeString = (maximumLength: number) =>
z
.string()
.trim()
.min(1)
.max(maximumLength)
.refine(
(value) =>
[...value].every((character) => {
const code = character.charCodeAt(0)
return code > 31 && code !== 127
}),
'值包含控制字符'
)
export const runtimeTargetSchema = z.enum([
'model',
'opencode',
'continue',
'deepseek-harness'
])
export type RuntimeTarget = z.infer<typeof runtimeTargetSchema>
export const capabilityAssignmentsSchema = z
.array(runtimeTargetSchema)
.max(4)
.refine(
(assignments) => new Set(assignments).size === assignments.length,
'Runtime 分配不能重复'
)
export type CapabilityAssignments = z.infer<
typeof capabilityAssignmentsSchema
>
export const builtinMcpServerIdSchema = z.enum([
'knowledge-base',
'magic-notes',
'goodbuddy-config',
'builtin-browser',
'obsidian'
])
export type BuiltinMcpServerId = z.infer<
typeof builtinMcpServerIdSchema
>
export const builtinMcpAssignmentsSchema =
capabilityAssignmentsSchema.refine(
(assignments) => !assignments.includes('deepseek-harness'),
'DeepSeek Harness 当前不支持内置 MCP'
)
export const builtinMcpServerToggleInputSchema = z
.object({
serverId: builtinMcpServerIdSchema,
enabled: z.boolean()
})
.strict()
export const builtinMcpServerAssignmentsInputSchema = z
.object({
serverId: builtinMcpServerIdSchema,
assignments: builtinMcpAssignmentsSchema
})
.strict()
export const builtinMcpServerStateSummarySchema = z
.object({
id: builtinMcpServerIdSchema,
enabled: z.boolean(),
assignments: builtinMcpAssignmentsSchema
})
.strict()
export type BuiltinMcpServerStateSummary = z.infer<
typeof builtinMcpServerStateSummarySchema
>
export const secretActionSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('keep') }).strict(),
z
.object({
action: z.literal('replace'),
value: controlCharacterFreeString(8_192)
})
.strict(),
z.object({ action: z.literal('clear') }).strict()
])
export type SecretAction = z.infer<typeof secretActionSchema>
export const skillIdSchema = z
.string()
.min(1)
.max(128)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
export const skillImportKindSchema = z.enum(['directory', 'zip'])
export type SkillImportKind = z.infer<typeof skillImportKindSchema>
export const skillToggleInputSchema = z
.object({
skillId: skillIdSchema,
enabled: z.boolean()
})
.strict()
export const skillAssignmentsInputSchema = z
.object({
skillId: skillIdSchema,
assignments: capabilityAssignmentsSchema
})
.strict()
export const skillSummarySchema = z
.object({
id: skillIdSchema,
name: z.string().min(1).max(80),
description: z.string().min(1).max(500),
version: z.string().max(32).optional(),
tags: z.array(z.string().min(1).max(32)).max(12),
source: z.enum(['builtin', 'imported']),
digest: z.string().regex(/^[a-f0-9]{64}$/),
enabled: z.boolean(),
assignments: capabilityAssignmentsSchema
})
.strict()
export type SkillSummary = z.infer<typeof skillSummarySchema>
export const computerCapabilityIdSchema = z.enum([
'host-browser-control',
'linux-desktop-control'
])
export type ComputerCapabilityId = z.infer<
typeof computerCapabilityIdSchema
>
export const browserProfileIdSchema = z.string().uuid()
export const browserProfileNameSchema = controlCharacterFreeString(80)
export const browserProfileCreateInputSchema = z
.object({
name: browserProfileNameSchema
})
.strict()
export type BrowserProfileCreateInput = z.infer<
typeof browserProfileCreateInputSchema
>
export const browserProfileRenameInputSchema = z
.object({
profileId: browserProfileIdSchema,
name: browserProfileNameSchema
})
.strict()
export type BrowserProfileRenameInput = z.infer<
typeof browserProfileRenameInputSchema
>
export const browserProfileSelectionInputSchema = z
.object({
profileId: browserProfileIdSchema
})
.strict()
export const browserProfileSummarySchema = z
.object({
id: browserProfileIdSchema,
name: browserProfileNameSchema,
mode: z.literal('managed-isolated')
})
.strict()
export type BrowserProfileSummary = z.infer<
typeof browserProfileSummarySchema
>
export const browserProfilesSummarySchema = z
.object({
profiles: z.array(browserProfileSummarySchema).max(32),
defaultProfileId: browserProfileIdSchema.nullable()
})
.strict()
export type BrowserProfilesSummary = z.infer<
typeof browserProfilesSummarySchema
>
export const computerCapabilityToggleInputSchema = z
.object({
capabilityId: computerCapabilityIdSchema,
enabled: z.boolean()
})
.strict()
export const computerCapabilityConfigInputSchema = z
.object({
capabilityId: computerCapabilityIdSchema,
browserProfileId: browserProfileIdSchema.nullable()
})
.strict()
export const computerCapabilityConfigSummarySchema = z
.object({
id: computerCapabilityIdSchema,
name: z.string().min(1).max(80),
description: z.string().min(1).max(500),
enabled: z.boolean(),
supported: z.boolean(),
browserProfileId: browserProfileIdSchema.nullable(),
riskSummary: z.string().min(1).max(500)
})
.strict()
export type ComputerCapabilityConfigSummary = z.infer<
typeof computerCapabilityConfigSummarySchema
>
export const capabilityDiagnosticStatusSchema = z.enum([
'available',
'degraded',
'unavailable',
'disabled'
])
export type CapabilityDiagnosticStatus = z.infer<
typeof capabilityDiagnosticStatusSchema
>
export const capabilityDiagnosticCheckStatusSchema =
capabilityDiagnosticStatusSchema.exclude(['disabled'])
export const capabilityDiagnosticCheckSchema = z
.object({
id: z
.string()
.min(1)
.max(80)
.regex(/^[a-z][a-z0-9-]*$/u),
status: capabilityDiagnosticCheckStatusSchema,
summary: z.string().min(1).max(240),
remedy: z.string().min(1).max(400).optional()
})
.strict()
export const capabilityDiagnosticReportSchema = z
.object({
capabilityId: computerCapabilityIdSchema,
status: capabilityDiagnosticStatusSchema,
checkedAt: z.string().datetime(),
checks: z.array(capabilityDiagnosticCheckSchema).max(16)
})
.strict()
export type CapabilityDiagnosticReport = z.infer<
typeof capabilityDiagnosticReportSchema
>
export const mcpTransportSchema = z.enum(['stdio', 'http', 'sse'])
export type McpTransport = z.infer<typeof mcpTransportSchema>
export const mcpServerIdSchema = z.string().uuid()
const mcpServerNameSchema = controlCharacterFreeString(80)
const mcpServerDescriptionSchema = z.string().trim().max(500)
const mcpCommandSchema = controlCharacterFreeString(4_096)
const mcpArgumentSchema = controlCharacterFreeString(4_096)
const mcpRemoteUrlSchema = z
.string()
.url()
.max(2_048)
.superRefine((value, context) => {
if (!['http:', 'https:'].includes(new URL(value).protocol)) {
context.addIssue({
code: 'custom',
message: 'MCP URL 必须使用 HTTP 或 HTTPS'
})
}
})
const mcpCommonInputShape = {
name: mcpServerNameSchema,
description: mcpServerDescriptionSchema,
enabled: z.boolean(),
allowDynamicTools: z.boolean(),
assignments: capabilityAssignmentsSchema,
secret: secretActionSchema
}
export const mcpServerInputSchema = z.discriminatedUnion('transport', [
z
.object({
...mcpCommonInputShape,
transport: z.literal('stdio'),
command: mcpCommandSchema,
args: z.array(mcpArgumentSchema).max(64)
})
.strict(),
z
.object({
...mcpCommonInputShape,
transport: z.literal('http'),
url: mcpRemoteUrlSchema
})
.strict(),
z
.object({
...mcpCommonInputShape,
transport: z.literal('sse'),
url: mcpRemoteUrlSchema
})
.strict()
])
export type McpServerInput = z.infer<typeof mcpServerInputSchema>
export const mcpServerSummarySchema = z.discriminatedUnion('transport', [
z
.object({
id: mcpServerIdSchema,
name: mcpServerNameSchema,
description: mcpServerDescriptionSchema,
enabled: z.boolean(),
allowDynamicTools: z.boolean(),
assignments: capabilityAssignmentsSchema,
secretConfigured: z.boolean(),
transport: z.literal('stdio'),
command: mcpCommandSchema,
args: z.array(mcpArgumentSchema).max(64)
})
.strict(),
z
.object({
id: mcpServerIdSchema,
name: mcpServerNameSchema,
description: mcpServerDescriptionSchema,
enabled: z.boolean(),
allowDynamicTools: z.boolean(),
assignments: capabilityAssignmentsSchema,
secretConfigured: z.boolean(),
transport: z.literal('http'),
url: mcpRemoteUrlSchema
})
.strict(),
z
.object({
id: mcpServerIdSchema,
name: mcpServerNameSchema,
description: mcpServerDescriptionSchema,
enabled: z.boolean(),
allowDynamicTools: z.boolean(),
assignments: capabilityAssignmentsSchema,
secretConfigured: z.boolean(),
transport: z.literal('sse'),
url: mcpRemoteUrlSchema
})
.strict()
])
export type McpServerSummary = z.infer<typeof mcpServerSummarySchema>
export const webSearchCapabilitySchema = z
.object({
provider: z.literal('exa'),
enabled: z.boolean(),
availableIn: z.tuple([z.literal('ask'), z.literal('execute')]),
tools: z.tuple([
z.literal('web_search'),
z.literal('web_fetch')
])
})
.strict()
export type WebSearchCapability = z.infer<
typeof webSearchCapabilitySchema
>
export const obsidianSettingsSchema = z
.object({ vaultPath: z.string().trim().max(4096).default('') })
.strict()
export type ObsidianSettings = z.infer<typeof obsidianSettingsSchema>
export const obsidianConnectionTestResultSchema = z.object({
vaults: z.array(z.object({
id: z.string(),
name: z.string(),
path: z.string()
}).strict()),
toolCount: z.number().int().min(0)
}).strict()
export type ObsidianConnectionTestResult = z.infer<typeof obsidianConnectionTestResultSchema>
export const capabilitySnapshotSchema = z
.object({
skills: z.array(skillSummarySchema).max(256),
imageGeneration: z
.object({
modelProfiles: z.array(z.object({
id: z.string().uuid(),
name: z.string()
}).strict()).max(20),
assignments: capabilityAssignmentsSchema
})
.strict()
.optional(),
builtinMcpServers: z
.array(builtinMcpServerStateSummarySchema)
.max(5)
.optional(),
obsidian: obsidianSettingsSchema.default({ vaultPath: '' }),
mcpServers: z.array(mcpServerSummarySchema).max(64),
webSearch: webSearchCapabilitySchema.optional(),
computerCapabilities: z
.array(computerCapabilityConfigSummarySchema)
.max(2)
.optional(),
browserProfiles: browserProfilesSummarySchema.optional(),
warnings: settingsWarningsSchema.optional()
})
.strict()
export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema>
export const mcpServerTestResultSchema = z
.object({
serverName: z.string().min(1).max(120).optional(),
serverVersion: z.string().min(1).max(64).optional(),
dynamicToolsSupported: z.boolean(),
toolCount: z.number().int().min(0).max(10_000),
tools: z
.array(
z
.object({
name: z.string().min(1).max(128),
description: z.string().max(500).optional()
})
.strict()
)
.max(100),
promptCount: z.number().int().min(0).max(10_000).optional(),
prompts: z
.array(
z
.object({
name: z.string().min(1).max(128),
description: z.string().max(500).optional(),
arguments: z
.array(
z
.object({
name: z.string().min(1).max(128),
description: z.string().max(500).optional(),
required: z.boolean()
})
.strict()
)
.max(32)
})
.strict()
)
.max(100)
.optional(),
resourceCount: z.number().int().min(0).max(10_000).optional(),
resources: z
.array(
z
.object({
name: z.string().min(1).max(200),
uri: z.string().min(1).max(2_048),
description: z.string().max(500).optional(),
mimeType: z.string().min(1).max(200).optional()
})
.strict()
)
.max(100)
.optional(),
promptsSupported: z.boolean().optional(),
resourcesSupported: z.boolean().optional()
})
.strict()
export type McpServerTestResult = z.infer<
typeof mcpServerTestResultSchema
>
export const webSearchTestResultSchema = z
.object({
provider: z.literal('exa'),
query: z.string().min(1).max(120),
durationMs: z.number().int().min(0),
preview: z.string().min(1).max(500)
})
.strict()
export type WebSearchTestResult = z.infer<
typeof webSearchTestResultSchema
>