3 Commits

Author SHA1 Message Date
177150086f feat(copilot): managed profile testing and openaiCompatible provider support
- add native openaiCompatible provider type with flat baseUrl/apiKey/dialect
  config fields and declared per-model capabilities
- add admin-only probeManagedCopilotProfile GraphQL mutation that dispatches
  real probe requests against server-managed copilot profiles, reusing the
  BYOK probe engine with an AllowPrivate egress policy for self-hosted
  endpoints (Vertex profiles rejected; use workspace BYOK instead)
- expose providers.profiles app config descriptor to the admin console
- add 'AI Providers' settings group with JSON editor and per-profile Test
  action that probes declared capabilities and reports verified/failed
  status per model and operation
2026-08-26 09:51:52 +07:00
ff599ac7f1 update 2026-08-25 22:01:15 +07:00
c256de50aa feat(mcp): expand MCP server toolset to Notion-level parity
- Ungate write tools (create/update/update_meta) from dev/canary env flag;
  now available in all environments with READ_WRITE credentials
- Add list_documents: root-doc snapshot based listing with permission
  filter, pagination and title fallback parsing for unmerged docs
- Add get_workspace_info: workspace name and member count
- Add get_users: searchable member list (id/name/email/avatar/role)
- Add get_comments / create_comment: comment roundtrip via CommentModel
  gated by Doc.Comments.Create permission
- Enable READ_WRITE credential creation in all environments (resolver)
- Bump MCP server version to 1.1.0
- Extend copilot e2e tests: full toolset assertions, doc/comment
  roundtrip over HTTP, read-only isolation
2026-08-25 15:54:11 +07:00
28 changed files with 1802 additions and 71 deletions

View File

@@ -1467,6 +1467,237 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "boolean",
"default": false
},
"providers.profiles": {
"type": "array",
"description": "The profile list for copilot providers.\n@default []",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CopilotManagedCapabilityToken": {
"enum": [
"chat",
"tools",
"vision",
"structured",
"embedding",
"rerank",
"image"
],
"type": "string"
},
"CopilotManagedModelConfigFile": {
"anyOf": [
{
"type": "string"
},
{
"properties": {
"capabilities": {
"default": [],
"items": {
"$ref": "#/definitions/CopilotManagedCapabilityToken"
},
"type": "array"
},
"enabled": {
"default": false,
"type": "boolean"
},
"id": {
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
}
]
},
"CopilotManagedProfileConfigFile": {
"properties": {
"apiKey": {
"default": null,
"type": [
"string",
"null"
]
},
"baseUrl": {
"default": null,
"type": [
"string",
"null"
]
},
"config": {
"additionalProperties": true,
"default": {},
"description": "Legacy nested form. New configs use the flat baseUrl/apiKey/dialect fields.",
"type": "object"
},
"dialect": {
"default": null,
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"enabled": {
"default": true,
"type": "boolean"
},
"id": {
"type": "string"
},
"middleware": {
"anyOf": [
{
"$ref": "#/definitions/CopilotProviderMiddlewareConfigFile"
},
{
"type": "null"
}
]
},
"models": {
"items": {
"$ref": "#/definitions/CopilotManagedModelConfigFile"
},
"type": [
"array",
"null"
]
},
"priority": {
"format": "double",
"type": [
"number",
"null"
]
},
"type": {
"$ref": "#/definitions/CopilotManagedProvider"
}
},
"required": [
"id",
"type"
],
"type": "object"
},
"CopilotManagedProvider": {
"enum": [
"anthropic",
"anthropicVertex",
"cloudflareWorkersAi",
"fal",
"gemini",
"geminiVertex",
"openai",
"openaiCompatible"
],
"type": "string"
},
"CopilotNodeMiddlewareConfigFile": {
"properties": {
"text": {
"items": {
"$ref": "#/definitions/CopilotNodeTextMiddleware"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"CopilotNodeTextMiddleware": {
"enum": [
"citation_footnote",
"callout",
"thinking_format"
],
"type": "string"
},
"CopilotProviderMiddlewareConfigFile": {
"properties": {
"node": {
"anyOf": [
{
"$ref": "#/definitions/CopilotNodeMiddlewareConfigFile"
},
{
"type": "null"
}
]
},
"rust": {
"anyOf": [
{
"$ref": "#/definitions/CopilotRustMiddlewareConfigFile"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"CopilotRustMiddlewareConfigFile": {
"properties": {
"request": {
"items": {
"$ref": "#/definitions/CopilotRustRequestMiddleware"
},
"type": [
"array",
"null"
]
},
"stream": {
"items": {
"$ref": "#/definitions/CopilotRustStreamMiddleware"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"CopilotRustRequestMiddleware": {
"enum": [
"normalize_messages",
"clamp_max_tokens",
"tool_schema_rewrite",
"openai_request_compat",
"omit_tool_choice"
],
"type": "string"
},
"CopilotRustStreamMiddleware": {
"enum": [
"stream_event_normalize",
"citation_indexing"
],
"type": "string"
}
},
"items": {
"$ref": "#/definitions/CopilotManagedProfileConfigFile"
},
"title": "Array_of_CopilotManagedProfileConfigFile",
"default": []
}
}
},

60
docker-compose.gitea.yml Normal file
View File

@@ -0,0 +1,60 @@
# AFFiNE self-hosted — image built from Dockerfile.all-in-one (MCP 1.1.0)
#
# Usage:
# docker compose up -d
# open http://localhost:3010
services:
affine:
image: gitea.luulam.dev/luulam/affine:latest
restart: unless-stopped
ports:
- '3010:3010'
# Image entrypoint does not run migrations; do it once on first boot
command: >
sh -c "node node_modules/prisma/build/index.js migrate deploy &&
node ./dist/main.js"
environment:
# All-in-one flavor runs sync + graphql + front in one process
- FLAVOR=allinone
- DATABASE_URL=postgresql://affine:affine@postgres:5432/affine
- REDIS_SERVER_HOST=redis
- REDIS_SERVER_PORT=6379
# Signer for auth tokens (auth) — change in production!
- AFFINE_SECRET=change-me-to-a-random-string
# Blob storage on local disk
- AFFINE_STORAGE_LOCAL=true
volumes:
- affine-storage:/app/storage
- affine-config:/app/config
postgres:
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=affine
- POSTGRES_PASSWORD=affine
- POSTGRES_DB=affine
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U affine -d affine']
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7
restart: unless-stopped
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 20
volumes:
postgres-data:
redis-data:
affine-storage:
affine-config:

View File

@@ -87,6 +87,7 @@ export declare class BackendRuntime {
rotateByokCredential(input: RotateByokCredentialInput): Promise<ByokProfileOutput>
probeByokProfile(input: ProbeByokProfileInput): Promise<ByokProbeResultOutput>
probeByokDraft(input: ProbeByokDraftInput): Promise<ByokProbeResultOutput>
probeManagedCopilotProfile(profileId: string, checks: Array<ByokProbeCheckInput>): Promise<ByokProbeResultOutput>
deleteByokProfile(workspaceId: string, profileId: string): Promise<boolean>
reorderByokProfiles(input: ReorderByokProfilesInput): Promise<Array<ByokProfileOutput>>
createByokLocalLease(input: CreateByokLocalLeaseInput): Promise<ByokLocalLeaseOutput>

View File

@@ -3,6 +3,7 @@ mod probe;
mod profile;
pub(super) use local::{LocalLeasePayload, create as create_local_lease};
pub(in crate::runtime::backend_runtime) use probe::execute_probe_with_policy;
pub(super) use profile::{create, delete, list, probe_draft, probe_profile, reorder, replace, rotate};
use profile::{envelope_key, require_text};

View File

@@ -28,6 +28,26 @@ pub(super) async fn execute_probe(
credential: SensitiveCredential,
policy: &ByokPolicy,
checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> {
execute_probe_with_policy(
provider,
definition,
credential,
policy.egress_policy(&definition.endpoint),
checks,
)
.await
}
/// Probe with an explicit egress policy. Managed (admin-configured) profiles
/// pass `AllowPrivate` for self-hosted endpoints; workspace BYOK always
/// derives the policy from [`ByokPolicy`].
pub(in crate::runtime::backend_runtime) async fn execute_probe_with_policy(
provider: &str,
definition: &ByokProfileDefinition,
credential: SensitiveCredential,
egress_policy: EgressPolicy,
checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> {
let tested_at_ms = chrono::Utc::now().timestamp_millis();
let mut requested = Vec::new();
@@ -71,7 +91,6 @@ pub(super) async fn execute_probe(
let credential = String::from_utf8(credential.expose().to_vec())
.map_err(|_| RuntimeError::invalid_state("credential_unavailable"))?;
let operation_for_task = operation.clone();
let egress_policy = policy.egress_policy(&endpoint);
tokio::task::spawn_blocking(move || {
dispatch_check(
&provider,

View File

@@ -194,7 +194,7 @@ fn load_managed_profiles(
.providers
.profiles
.iter()
.filter(|profile| profile.enabled && profile.models.iter().any(|model| model == model_id))
.filter(|profile| profile.enabled && profile.models.iter().any(|model| model.id == *model_id))
.collect::<Vec<_>>();
let Some(profile) = matches.first() else {
return Ok(None);
@@ -204,15 +204,23 @@ fn load_managed_profiles(
"built-in managed route model matches multiple profiles",
));
}
let capabilities = provider_default_capability_upper_bound(&profile.provider, model_id)
.ok_or_else(|| RuntimeError::invalid_state("built-in managed route model is incompatible with its profile"))?;
let declared = matches
.first()
.and_then(|profile| profile.models.iter().find(|model| model.id == *model_id))
.and_then(|model| model.capabilities.clone());
let capabilities = match declared {
Some(capabilities) => capabilities,
None => provider_default_capability_upper_bound(&profile.provider, model_id).ok_or_else(|| {
RuntimeError::invalid_state("built-in managed route model is incompatible with its profile")
})?,
};
let endpoint = managed_endpoint(profile)?;
Ok(Some(AuthorizedProviderProfile {
profile_id: profile.id.clone(),
source: ProfileSource::Managed,
provider: profile.provider.clone(),
endpoint,
openai_dialect: (profile.provider == "openai").then_some(OpenAiDialect::Responses),
openai_dialect: openai_dialect_for(profile),
egress_policy: llm_adapter::target::EgressPolicy::PublicOnly,
models: vec![crate::llm::byok::ByokModelDeclaration {
model_id: model_id.clone(),
@@ -229,6 +237,19 @@ fn load_managed_profiles(
.collect()
}
fn openai_dialect_for(profile: &CopilotManagedProfileConfig) -> Option<OpenAiDialect> {
match profile.provider.as_str() {
"openai" => Some(OpenAiDialect::Responses),
"openaiCompatible" => Some(
match profile.config.get("dialect").and_then(serde_json::Value::as_str) {
Some("responses") => OpenAiDialect::Responses,
_ => OpenAiDialect::ChatCompletions,
},
),
_ => None,
}
}
fn managed_endpoint(profile: &CopilotManagedProfileConfig) -> RuntimeResult<BackendEndpoint> {
if let Some(base_url) = profile.config.get("baseURL").and_then(serde_json::Value::as_str) {
return llm_adapter::target::canonicalize_endpoint(base_url)
@@ -315,14 +336,18 @@ pub(super) fn required_config_text<'a>(
mod tests {
use serde_json::json;
use super::{BackendEndpoint, CopilotManagedProfileConfig, managed_endpoint};
use super::{BackendEndpoint, CopilotManagedProfileConfig, managed_endpoint, openai_dialect_for};
use crate::runtime::config::CopilotManagedModel;
fn vertex_profile(location: &str) -> CopilotManagedProfileConfig {
CopilotManagedProfileConfig {
id: "vertex".to_string(),
provider: "geminiVertex".to_string(),
enabled: true,
models: vec!["gemini-3.7-flash".to_string()],
models: vec![CopilotManagedModel {
id: "gemini-3.7-flash".to_string(),
capabilities: None,
}],
config: json!({ "project": "affine-us", "location": location }),
}
}
@@ -347,4 +372,32 @@ mod tests {
)
);
}
#[test]
fn openai_compatible_dialect_defaults_to_chat_completions() {
let profile = CopilotManagedProfileConfig {
id: "vllm".to_string(),
provider: "openaiCompatible".to_string(),
enabled: true,
models: vec![CopilotManagedModel {
id: "qwen3-32b".to_string(),
capabilities: None,
}],
config: json!({ "baseURL": "http://127.0.0.1:8000/v1", "apiKey": "x" }),
};
assert!(managed_endpoint(&profile).is_ok());
assert_eq!(
openai_dialect_for(&profile),
Some(llm_adapter::target::OpenAiDialect::ChatCompletions)
);
let profile = CopilotManagedProfileConfig {
config: json!({ "dialect": "responses" }),
..profile
};
assert_eq!(
openai_dialect_for(&profile),
Some(llm_adapter::target::OpenAiDialect::Responses)
);
}
}

View File

@@ -344,6 +344,7 @@ pub(super) async fn create_vertex_token_provider(
pub(in crate::runtime::backend_runtime) fn provider(value: &str) -> RuntimeResult<BackendProvider> {
match value {
"openai" => Ok(BackendProvider::OpenAi),
"openaiCompatible" => Ok(BackendProvider::OpenAi),
"anthropic" => Ok(BackendProvider::Anthropic),
"anthropicVertex" => Ok(BackendProvider::AnthropicVertex),
"gemini" => Ok(BackendProvider::Gemini),

View File

@@ -0,0 +1,93 @@
use std::collections::HashSet;
use llm_adapter::target::EgressPolicy;
use crate::{
llm::{
ByokProbeCheckInput, ByokProbeResultOutput,
byok::{ByokEndpoint, SensitiveCredential},
},
runtime::{BackendRuntimeConfig, CopilotManagedProfileConfig, RuntimeError, RuntimeResult},
};
/// Build a BYOK-equivalent definition from a managed profile so the shared
/// probe engine can compile targets and dispatch real requests. Managed
/// profiles are admin-controlled, so private endpoints (self-hosted vLLM,
/// Ollama, LiteLLM) are allowed and no DNS admission check applies.
pub(super) fn managed_definition(
managed: &CopilotManagedProfileConfig,
) -> RuntimeResult<(crate::llm::byok::ByokProfileDefinition, EgressPolicy)> {
use crate::llm::byok::{ByokModelDeclaration, ByokProfileDefinition};
let endpoint = if let Some(base_url) = managed.config.get("baseURL").and_then(serde_json::Value::as_str) {
ByokEndpoint::OpenAiCompatible {
url: llm_adapter::target::canonicalize_endpoint(base_url)
.map_err(|error| RuntimeError::invalid_state(error.to_string()))?,
dialect: openai_dialect(managed).unwrap_or(llm_adapter::target::OpenAiDialect::ChatCompletions),
}
} else {
ByokEndpoint::ProviderDefault
};
let mut ids = HashSet::new();
let models = managed
.models
.iter()
.map(|model| {
if !ids.insert(model.id.clone()) {
return Err(RuntimeError::invalid_state(
"managed copilot profile models must be unique",
));
}
Ok(ByokModelDeclaration {
model_id: model.id.clone(),
enabled: true,
capabilities: model.capabilities.clone().unwrap_or_default(),
})
})
.collect::<RuntimeResult<Vec<_>>>()?;
let egress_policy = if matches!(endpoint, ByokEndpoint::OpenAiCompatible { .. }) {
EgressPolicy::AllowPrivate
} else {
EgressPolicy::PublicOnly
};
Ok((ByokProfileDefinition { endpoint, models }, egress_policy))
}
fn openai_dialect(managed: &CopilotManagedProfileConfig) -> Option<llm_adapter::target::OpenAiDialect> {
match managed.provider.as_str() {
"openai" => Some(llm_adapter::target::OpenAiDialect::Responses),
"openaiCompatible" => Some(
match managed.config.get("dialect").and_then(serde_json::Value::as_str) {
Some("responses") => llm_adapter::target::OpenAiDialect::Responses,
_ => llm_adapter::target::OpenAiDialect::ChatCompletions,
},
),
_ => None,
}
}
/// Probe a managed profile from the active runtime config without touching
/// per-workspace BYOK storage. Used by the admin console "Test" action.
pub(in crate::runtime::backend_runtime) async fn probe_managed(
config: &BackendRuntimeConfig,
profile_id: &str,
checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> {
let managed = super::context::managed_profile(&config.copilot, profile_id)?;
let (definition, egress_policy) = managed_definition(managed)?;
// Vertex credentials need a token provider; static providers read config.
if matches!(managed.provider.as_str(), "geminiVertex" | "anthropicVertex") {
return Err(RuntimeError::invalid_input(
"probe for vertex managed profiles is not supported; use workspace BYOK",
));
}
let credential = super::dispatch::managed_credential(managed, None).await?;
crate::runtime::backend_runtime::byok::execute_probe_with_policy(
&managed.provider,
&definition,
SensitiveCredential::new(credential.into_bytes()),
egress_policy,
checks,
)
.await
}

View File

@@ -1,5 +1,6 @@
mod context;
mod dispatch;
pub(in crate::runtime::backend_runtime) mod managed_probe;
mod stream;
use std::{

View File

@@ -45,9 +45,9 @@ pub(super) use super::{
napi_error, to_napi_error, webpki_tls_config,
};
use crate::llm::{
ByokLocalLeaseOutput, ByokPolicyOutput, ByokProbeResultOutput, ByokProfileOutput, CreateByokLocalLeaseInput,
CreateByokProfileInput, ProbeByokDraftInput, ProbeByokProfileInput, ReorderByokProfilesInput,
ReplaceByokProfileInput, RotateByokCredentialInput,
ByokLocalLeaseOutput, ByokPolicyOutput, ByokProbeCheckInput, ByokProbeResultOutput, ByokProfileOutput,
CreateByokLocalLeaseInput, CreateByokProfileInput, ProbeByokDraftInput, ProbeByokProfileInput,
ReorderByokProfilesInput, ReplaceByokProfileInput, RotateByokCredentialInput,
};
pub(super) fn token_hash(token: &str) -> String {
@@ -710,6 +710,18 @@ impl BackendRuntime {
.map_err(to_napi_error)
}
#[napi]
pub async fn probe_managed_copilot_profile(
&self,
profile_id: String,
checks: Vec<ByokProbeCheckInput>,
) -> Result<ByokProbeResultOutput> {
let config = self.config()?;
copilot::managed_probe::probe_managed(&config, &profile_id, checks)
.await
.map_err(to_napi_error)
}
#[napi]
pub async fn delete_byok_profile(&self, workspace_id: String, profile_id: String) -> Result<bool> {
let deleted = byok::delete(&self.pool().await?, &workspace_id, &profile_id)

View File

@@ -5,7 +5,7 @@ use std::{
sync::Arc,
};
use llm_adapter::capability::provider_default_capability_upper_bound;
use llm_adapter::capability::{DeclaredModelCapability, ModelFeature, provider_default_capability_upper_bound};
use serde::Deserialize;
use serde_json::Map;
use sqlx::{PgPool, Row};
@@ -99,9 +99,7 @@ impl ConfigSource {
self.exact() || self.override_path.as_deref() == Some(path)
}
}
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[derive(Clone, Default)]
pub(crate) struct CopilotRuntimeConfig {
pub(crate) enabled: bool,
pub(crate) byok: CopilotByokRuntimeConfig,
@@ -135,25 +133,26 @@ fn default_allowed_providers() -> Vec<String> {
SUPPORTED_BYOK_PROVIDERS.into_iter().map(str::to_string).collect()
}
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[derive(Clone, Default)]
pub(crate) struct CopilotProvidersRuntimeConfig {
pub(crate) profiles: Vec<CopilotManagedProfileConfig>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Clone)]
pub(crate) struct CopilotManagedProfileConfig {
pub(crate) id: String,
#[serde(rename = "type")]
pub(crate) provider: String,
#[serde(default = "enabled_by_default")]
pub(crate) enabled: bool,
#[serde(default)]
pub(crate) models: Vec<String>,
pub(crate) models: Vec<CopilotManagedModel>,
pub(crate) config: serde_json::Value,
}
#[derive(Clone)]
pub(crate) struct CopilotManagedModel {
pub(crate) id: String,
pub(crate) capabilities: Option<Vec<DeclaredModelCapability>>,
}
fn enabled_by_default() -> bool {
true
}
@@ -182,11 +181,44 @@ pub(crate) struct CopilotManagedProfileConfigFile {
priority: Option<f64>,
#[serde(default = "enabled_by_default")]
enabled: bool,
models: Option<Vec<String>>,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
dialect: Option<String>,
models: Option<Vec<CopilotManagedModelConfigFile>>,
middleware: Option<CopilotProviderMiddlewareConfigFile>,
/// Legacy nested form. New configs use the flat baseUrl/apiKey/dialect fields.
#[serde(default)]
config: Map<String, serde_json::Value>,
}
#[derive(Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(untagged)]
pub(crate) enum CopilotManagedModelConfigFile {
Id(String),
Declared {
id: String,
#[serde(default)]
enabled: bool,
#[serde(default)]
capabilities: Vec<CopilotManagedCapabilityToken>,
},
}
#[derive(Clone, Copy, PartialEq, Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CopilotManagedCapabilityToken {
Chat,
Tools,
Vision,
Structured,
Embedding,
Rerank,
Image,
}
#[derive(Clone, Copy, Deserialize, serde::Serialize, schemars::JsonSchema)]
enum CopilotManagedProvider {
#[serde(rename = "anthropic")]
@@ -203,6 +235,8 @@ enum CopilotManagedProvider {
GeminiVertex,
#[serde(rename = "openai")]
OpenAi,
#[serde(rename = "openaiCompatible")]
OpenAiCompatible,
}
impl CopilotManagedProvider {
@@ -215,12 +249,14 @@ impl CopilotManagedProvider {
Self::Gemini => "gemini",
Self::GeminiVertex => "geminiVertex",
Self::OpenAi => "openai",
Self::OpenAiCompatible => "openaiCompatible",
}
}
fn legacy_models(self) -> Vec<String> {
let models: &[&str] = match self {
Self::OpenAi => &["gpt-5.6-luna", "gpt-5.6-terra", "gpt-image-1", "gpt-4o-mini"],
Self::OpenAiCompatible => &[],
Self::CloudflareWorkersAi => &["@cf/baai/bge-reranker-base"],
Self::Fal => &["lora/image-to-image", "workflowutils/teed"],
Self::Gemini => &["gemini-3.7-flash", "gemini-embedding-001"],
@@ -273,6 +309,65 @@ enum CopilotNodeTextMiddleware {
ThinkingFormat,
}
impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
type Error = RuntimeError;
fn try_from(value: CopilotManagedProfileConfigFile) -> Result<Self, Self::Error> {
if value.id.is_empty()
|| !value
.id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(RuntimeError::invalid_state(
"managed copilot profile id must contain only letters, numbers, hyphens, and underscores",
));
}
let models = value.models.unwrap_or_else(|| {
value
.provider
.legacy_models()
.into_iter()
.map(|id| CopilotManagedModelConfigFile::Id(id))
.collect()
});
let models = models
.into_iter()
.map(|model| match model {
CopilotManagedModelConfigFile::Id(id) => Ok(CopilotManagedModel { id, capabilities: None }),
CopilotManagedModelConfigFile::Declared {
id,
enabled: _,
capabilities,
} => {
let capabilities = (!capabilities.is_empty())
.then(|| capabilities.iter().map(managed_capability).collect())
.transpose()?;
Ok(CopilotManagedModel { id, capabilities })
}
})
.collect::<RuntimeResult<Vec<_>>>()?;
let mut config = value.config;
if let Some(base_url) = value.base_url {
config.insert("baseURL".to_string(), serde_json::Value::String(base_url));
}
if let Some(api_key) = value.api_key {
config.insert("apiKey".to_string(), serde_json::Value::String(api_key));
}
if let Some(dialect) = value.dialect {
config.insert("dialect".to_string(), serde_json::Value::String(dialect));
}
Ok(Self {
id: value.id,
provider: value.provider.as_str().to_string(),
enabled: value.enabled,
models,
config: serde_json::Value::Object(config),
})
}
}
impl TryFrom<CopilotRuntimeConfigFile> for CopilotRuntimeConfig {
type Error = RuntimeError;
@@ -292,29 +387,62 @@ impl TryFrom<CopilotRuntimeConfigFile> for CopilotRuntimeConfig {
}
}
impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
type Error = RuntimeError;
fn try_from(value: CopilotManagedProfileConfigFile) -> Result<Self, Self::Error> {
if value.id.is_empty()
|| !value
.id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(RuntimeError::invalid_state(
"managed copilot profile id must contain only letters, numbers, hyphens, and underscores",
));
}
let models = value.models.unwrap_or_else(|| value.provider.legacy_models());
Ok(Self {
id: value.id,
provider: value.provider.as_str().to_string(),
enabled: value.enabled,
models,
config: serde_json::Value::Object(value.config),
})
}
fn managed_capability(token: &CopilotManagedCapabilityToken) -> RuntimeResult<DeclaredModelCapability> {
use llm_adapter::capability::{AttachmentKind, AttachmentSource, ModelInput, ModelOutput};
let capability = match token {
CopilotManagedCapabilityToken::Chat => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Text],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Tools => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Text],
features: vec![ModelFeature::ToolCalling],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Vision => DeclaredModelCapability {
input: vec![ModelInput::Text, ModelInput::Image],
output: vec![ModelOutput::Text],
features: vec![],
attachment_kinds: vec![AttachmentKind::Image],
attachment_sources: vec![AttachmentSource::Url, AttachmentSource::Data, AttachmentSource::Bytes],
},
CopilotManagedCapabilityToken::Structured => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Structured],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Embedding => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Embedding],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Rerank => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Rerank],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Image => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Image],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
};
llm_adapter::capability::validate_declared_capability(&capability)
.map(|_| capability)
.map_err(|error| RuntimeError::invalid_state(format!("managed copilot profile capability invalid: {error}")))
}
#[derive(Clone, Debug)]
@@ -459,12 +587,17 @@ pub(super) fn validate_copilot_config(config: &CopilotRuntimeConfig) -> RuntimeR
}
let mut models = std::collections::HashSet::new();
for model in &profile.models {
if model.trim().is_empty() || !models.insert(model.as_str()) {
if model.id.trim().is_empty() || !models.insert(model.id.as_str()) {
return Err(RuntimeError::invalid_state(
"managed copilot profile models must be non-empty and unique",
));
}
provider_default_capability_upper_bound(&profile.provider, model)
// openaiCompatible endpoints serve arbitrary model ids; declared or
// probed capabilities replace the built-in catalog lookup.
if profile.provider == "openaiCompatible" {
continue;
}
provider_default_capability_upper_bound(&profile.provider, &model.id)
.ok_or_else(|| RuntimeError::invalid_state("managed copilot profile model is unsupported"))?;
}
}
@@ -854,7 +987,14 @@ mod tests {
.unwrap();
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
validate_copilot_config(&copilot).unwrap();
assert_eq!(copilot.providers.profiles[0].models, expected_models);
assert_eq!(
copilot.providers.profiles[0]
.models
.iter()
.map(|m| m.id.as_str())
.collect::<Vec<_>>(),
expected_models
);
}
let app_config = app_config_from_flat_overrides([(
@@ -1025,4 +1165,60 @@ mod tests {
Some("workspace_invitation")
);
}
#[test]
fn openai_compatible_profile_accepts_arbitrary_models_and_flat_fields() {
let app_config = app_config_from_module_json(serde_json::json!({
"copilot": {
"enabled": true,
"providers": {
"profiles": [{
"id": "my-vllm",
"type": "openaiCompatible",
"baseUrl": "http://127.0.0.1:8000/v1",
"apiKey": "sk-test",
"models": [
"qwen3-32b",
{ "id": "bge-m3", "capabilities": ["embedding"] }
]
}]
}
}
}))
.unwrap();
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
validate_copilot_config(&copilot).unwrap();
let profile = &copilot.providers.profiles[0];
assert_eq!(profile.provider, "openaiCompatible");
assert_eq!(profile.models.len(), 2);
assert_eq!(profile.models[0].id, "qwen3-32b");
assert!(profile.models[0].capabilities.is_none());
assert_eq!(
profile.config.get("baseURL").and_then(serde_json::Value::as_str),
Some("http://127.0.0.1:8000/v1")
);
assert_eq!(
profile.config.get("apiKey").and_then(serde_json::Value::as_str),
Some("sk-test")
);
let declared = profile.models[1].capabilities.as_ref().unwrap();
use llm_adapter::capability::ModelOutput;
assert!(
declared
.iter()
.any(|capability| capability.output.contains(&ModelOutput::Embedding))
);
// unknown provider types are rejected at deserialization, not validation
let app_config = app_config_from_flat_overrides([(
"copilot.providers.profiles",
serde_json::json!([{ "id": "x", "type": "totally-unknown-provider", "models": ["any"] }]),
)]);
assert!(
app_config.is_err(),
"unknown provider type must be rejected at deserialization"
);
}
}

View File

@@ -69,7 +69,7 @@ fn descriptors() -> Vec<AppConfigDescriptor> {
description: "The profile list for copilot providers.".to_string(),
default_value: json!(defaults.providers.profiles),
schema: schema_for::<Vec<CopilotManagedProfileConfigFile>>(),
internal: true,
internal: false,
},
]
}
@@ -161,7 +161,7 @@ mod tests {
]
);
assert_eq!(descriptors[0].default_value, json!(true));
assert!(descriptors[4].internal);
assert!(!descriptors[4].internal);
assert!(
validate_app_config_value(
"copilot".to_string(),
@@ -218,4 +218,35 @@ mod tests {
);
}
}
#[test]
fn openai_compatible_profiles_validate_with_flat_fields_and_arbitrary_models() {
assert!(
validate_app_config_value(
"copilot".to_string(),
"providers.profiles".to_string(),
json!([{
"id": "my-vllm",
"type": "openaiCompatible",
"enabled": true,
"baseUrl": "http://127.0.0.1:8000/v1",
"apiKey": "sk-test",
"dialect": "chat_completions",
"models": ["qwen3-32b", { "id": "bge-m3", "capabilities": ["embedding"] }]
}]),
)
.unwrap()
.is_empty()
);
// unknown model ids on catalog providers still fail
assert!(
!validate_app_config_value(
"copilot".to_string(),
"providers.profiles".to_string(),
json!([{ "id": "x", "type": "openai", "models": ["not-in-catalog"] }]),
)
.unwrap()
.is_empty()
);
}
}

View File

@@ -14,7 +14,7 @@ import { Models } from '../../models';
import { CopilotFeatureService } from '../../plugins/copilot/feature';
import { McpCredentialService } from '../../plugins/copilot/mcp/credential';
import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider';
import { installMockCopilotRuntime } from '../mocks';
import { installMockCopilotRuntime, Mockers } from '../mocks';
import { createTestingApp, createWorkspace, type TestingApp } from '../utils';
import {
chatWithImages,
@@ -250,7 +250,14 @@ test('MCP credentials remain endpoint-bound through rotate, revoke and expiry',
(await provider.for(user.id, target.id, McpAccessMode.READ_ONLY)).tools.map(
tool => tool.name
),
['read_document', 'doc_search']
[
'read_document',
'doc_search',
'list_documents',
'get_workspace_info',
'get_users',
'get_comments',
]
);
const rotated = await credentials.rotate(
@@ -283,3 +290,129 @@ test('MCP credentials remain endpoint-bound through rotate, revoke and expiry',
});
await t.throwsAsync(credentials.authenticate(disabled.token, target.id));
});
test('MCP server exposes full toolset with read-write credential and exercises doc/comment tools', async t => {
const { app } = t.context;
const auth = app.get(AuthService);
const credentials = app.get(McpCredentialService);
const provider = app.get(WorkspaceMcpProvider);
const user = await auth.signUp(`mcp-rw-${randomUUID()}@affine.pro`, '123456');
const workspace = await app.create(Mockers.Workspace, {
owner: user,
snapshot: true,
});
const issued = await credentials.create({
userId: user.id,
workspaceId: workspace.id,
name: 'RW client',
accessMode: McpAccessMode.READ_WRITE,
expirationDays: 90,
});
// READ_WRITE no longer gated behind dev/canary
const toolNames = (
await provider.for(user.id, workspace.id, McpAccessMode.READ_WRITE)
).tools.map(tool => tool.name);
t.deepEqual(toolNames, [
'read_document',
'doc_search',
'create_document',
'update_document',
'update_document_meta',
'list_documents',
'get_workspace_info',
'create_comment',
'get_users',
'get_comments',
]);
const callTool = async (name: string, args: Record<string, unknown>) => {
const response = await app
.POST(`/api/workspaces/${workspace.id}/mcp`)
.set('Authorization', `Bearer ${issued.token}`)
.send({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name, arguments: args },
})
.expect(200);
const result = response.body.result as {
content?: { text: string }[];
isError?: boolean;
};
t.like(
{ error: result.isError ? result.content?.[0].text : null },
{ error: null }
);
t.truthy(result.content);
return result;
};
// create_document
const created = await callTool('create_document', {
title: 'MCP Test Doc',
content: '# MCP Test Doc\n\nHello from MCP.',
});
const docId = JSON.parse(created.content![0].text).docId as string;
const listed = await callTool('list_documents', {});
const docs = JSON.parse(listed.content![0].text).docs as {
doc_id: string;
title: string;
}[];
t.log('list_documents result:', listed.content?.[0].text);
t.true(
docs.some(doc => doc.doc_id === docId && doc.title === 'MCP Test Doc')
);
// get_workspace_info
const wsInfo = await callTool('get_workspace_info', {});
t.like(JSON.parse(wsInfo.content![0].text), {
workspace_id: workspace.id,
member_count: 1,
});
// get_users contains owner
const users = await callTool('get_users', {});
const userList = JSON.parse(users.content![0].text).users as { id: string }[];
t.true(userList.some(u => u.id === user.id));
// create_comment + get_comments roundtrip
const commented = await callTool('create_comment', {
docId,
content: 'A comment from MCP',
});
const commentId = JSON.parse(commented.content![0].text).comment_id as string;
t.truthy(commentId);
const comments = await callTool('get_comments', { docId });
const parsed = JSON.parse(comments.content![0].text) as {
comments: { id: string; content: unknown }[];
};
t.is(parsed.comments.length, 1);
t.is(parsed.comments[0].id, commentId);
t.like(parsed.comments[0].content, {
type: 'paragraph',
content: [{ type: 'text', text: 'A comment from MCP' }],
});
});
test('MCP read-only credential cannot create documents or comments', async t => {
const { app } = t.context;
const auth = app.get(AuthService);
const models = app.get(Models);
const provider = app.get(WorkspaceMcpProvider);
const user = await auth.signUp(`mcp-ro-${randomUUID()}@affine.pro`, '123456');
const workspace = await models.workspace.create(user.id);
const readOnlyTools = (
await provider.for(user.id, workspace.id, McpAccessMode.READ_ONLY)
).tools.map(tool => tool.name);
t.deepEqual(readOnlyTools.sort(), [
'doc_search',
'get_comments',
'get_users',
'get_workspace_info',
'list_documents',
'read_document',
]);
});

View File

@@ -629,6 +629,15 @@ export class BackendRuntimeProvider
);
}
async probeManagedProfile(
profileId: string,
checks: Array<{ modelId: string; operation: string }>
): Promise<ByokProbeResultOutput> {
return await this.measured('probeManagedProfile', runtime =>
runtime.probeManagedCopilotProfile(profileId, checks)
);
}
async deleteByokProfile(workspaceId: string, profileId: string) {
return await this.measured('deleteByokProfile', runtime =>
runtime.deleteByokProfile(workspaceId, profileId)

View File

@@ -35,13 +35,17 @@ type CopilotProviderProfileCommon = {
displayName?: string;
priority?: number;
enabled?: boolean;
models?: string[];
baseUrl?: string;
apiKey?: string;
dialect?: 'responses' | 'chat_completions';
models?: Array<string | { id: string; capabilities?: string[] }>;
middleware?: ProviderMiddlewareConfig;
};
export type CopilotProviderProfile = CopilotProviderProfileCommon & {
type: CopilotProviderType;
config: ProviderSpecificConfig;
/** Legacy nested form. New configs use the flat baseUrl/apiKey/dialect fields. */
config?: ProviderSpecificConfig;
};
declare global {

View File

@@ -0,0 +1,118 @@
import {
Args,
Field,
ID,
InputType,
Mutation,
ObjectType,
Resolver,
} from '@nestjs/graphql';
import { Throttle } from '../../base';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { Admin } from '../../core/common';
import { ByokProbeOperation, ByokProbeStatusKind } from './byok/types';
@InputType()
class ManagedProfileProbeCheckInput {
@Field(() => String)
modelId!: string;
@Field(() => ByokProbeOperation)
operation!: ByokProbeOperation;
}
@ObjectType()
class ManagedProfileProbeStatusType {
@Field(() => ByokProbeStatusKind)
kind!: ByokProbeStatusKind;
@Field(() => Date, { nullable: true })
testedAt!: Date | null;
@Field(() => String, { nullable: true })
errorKind!: string | null;
}
@ObjectType()
class ManagedProfileModelProbeCheckType {
@Field(() => ByokProbeOperation)
operation!: ByokProbeOperation;
@Field(() => ManagedProfileProbeStatusType)
status!: ManagedProfileProbeStatusType;
}
@ObjectType()
class ManagedProfileModelProbeType {
@Field(() => String)
modelId!: string;
@Field(() => [ManagedProfileModelProbeCheckType])
checks!: ManagedProfileModelProbeCheckType[];
}
@ObjectType()
class ManagedProfileProbeResultType {
@Field(() => String)
definitionFingerprint!: string;
@Field(() => ManagedProfileProbeStatusType)
connection!: ManagedProfileProbeStatusType;
@Field(() => [ManagedProfileModelProbeType])
models!: ManagedProfileModelProbeType[];
}
function projectStatus(probe: {
kind: string;
testedAtMs?: number;
errorKind?: string;
}) {
return {
kind: probe.kind,
testedAt: probe.testedAtMs ? new Date(probe.testedAtMs) : null,
errorKind: probe.errorKind ?? null,
};
}
/**
* Admin-only surface for testing server-managed copilot provider profiles
* (copilot.providers.profiles) configured through the admin console.
*/
@Admin()
@Resolver(() => ManagedProfileProbeResultType)
export class ManagedCopilotProfileResolver {
constructor(private readonly runtime: BackendRuntimeProvider) {}
@Mutation(() => ManagedProfileProbeResultType, {
description:
'Test a server-managed copilot provider profile by dispatching real probe requests.',
})
@Throttle('strict')
async probeManagedCopilotProfile(
@Args('profileId', { type: () => ID }) profileId: string,
@Args('checks', { type: () => [ManagedProfileProbeCheckInput] })
checks: ManagedProfileProbeCheckInput[]
) {
const result = await this.runtime.probeManagedProfile(
profileId,
checks.map(check => ({
modelId: check.modelId,
operation: check.operation,
}))
);
return {
definitionFingerprint: result.definitionFingerprint,
stale: result.stale,
connection: projectStatus(result.connection),
models: result.models.map(model => ({
modelId: model.modelId,
checks: model.checks.map(check => ({
operation: check.operation,
status: projectStatus(check.status),
})),
})),
};
}
}

View File

@@ -2,8 +2,11 @@ import { Injectable } from '@nestjs/common';
import { McpAccessMode } from '@prisma/client';
import z from 'zod/v3';
import { PaginationInput } from '../../../base/graphql';
import { DocReader, DocWriter } from '../../../core/doc';
import { PermissionAccess } from '../../../core/permission';
import { PermissionAccess, PermissionService } from '../../../core/permission';
import { readAllDocIdsFromWorkspaceSnapshot } from '../../../core/utils/blocksuite';
import { Models, WorkspaceRole } from '../../../models';
import { DocumentRetrievalService } from '../retrieval/document';
type McpTextContent = {
@@ -100,7 +103,9 @@ export class WorkspaceMcpProvider {
private readonly ac: PermissionAccess,
private readonly reader: DocReader,
private readonly writer: DocWriter,
private readonly retrieval: DocumentRetrievalService
private readonly retrieval: DocumentRetrievalService,
private readonly models: Models,
private readonly permission: PermissionService
) {}
async for(
@@ -202,10 +207,7 @@ export class WorkspaceMcpProvider {
const tools = [readDocument, docSearch];
if (
accessMode === McpAccessMode.READ_WRITE &&
(env.dev || env.namespaces.canary)
) {
if (accessMode === McpAccessMode.READ_WRITE) {
const createDocument = defineTool({
name: 'create_document',
title: 'Create Document',
@@ -388,9 +390,297 @@ export class WorkspaceMcpProvider {
tools.push(createDocument, updateDocument, updateDocumentMeta);
}
const listDocuments = defineTool({
name: 'list_documents',
title: 'List Documents',
description:
'List documents in the workspace the credential owner can read, ordered by last update time (newest first). Returns doc IDs, titles and timestamps for pagination.',
parser: z.object({
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
}),
inputSchema: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 },
},
additionalProperties: false,
},
execute: async ({ limit, offset }, options) => {
await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.Read');
const abortedAfterPermission = abortIfNeeded(options.signal);
if (abortedAfterPermission) return abortedAfterPermission;
const pagination: PaginationInput = {
first: Math.min(limit ?? 20, 100),
offset: offset ?? 0,
};
const rootDoc = await this.reader.getDoc(workspaceId, workspaceId);
if (!rootDoc) {
return toolText(
JSON.stringify({ total: 0, offset: pagination.offset, docs: [] })
);
}
const docIds = readAllDocIdsFromWorkspaceSnapshot(rootDoc.bin);
const readable = await this.permission.filterReadableDocs({
userId,
workspaceId,
docs: docIds.map(docId => ({ docId })),
});
const infos = (
await Promise.all(
readable.map(async ({ docId }) => {
const info = await this.models.doc.getDocInfo(workspaceId, docId);
if (!info || !info.title) {
// Doc created but its updates have not been merged into a
// snapshot yet, so `workspace_pages` has no title. Parse the
// title from the pending yjs binary instead.
const markdown = await this.reader.getDocMarkdown(
workspaceId,
docId,
false
);
if (!markdown) return null;
return {
...info,
docId,
title: markdown.title,
createdAt: info?.createdAt ?? new Date(),
updatedAt: info?.updatedAt ?? new Date(),
};
}
return info;
})
)
).filter(
(info): info is NonNullable<typeof info> =>
info !== null && !!info.title
);
infos.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const page = infos.slice(
pagination.offset,
pagination.offset + pagination.first
);
return toolText(
JSON.stringify({
total: infos.length,
offset: pagination.offset,
docs: page.map(info => ({
doc_id: info.docId,
title: info.title,
created_at: info.createdAt,
updated_at: info.updatedAt,
})),
})
);
},
});
tools.push(listDocuments);
const getWorkspaceInfo = defineTool({
name: 'get_workspace_info',
title: 'Get Workspace Info',
description: 'Get the name and member count of the workspace.',
parser: z.object({}),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
execute: async (_args, options) => {
await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.Read');
const aborted = abortIfNeeded(options.signal);
if (aborted) return aborted;
const [workspace, memberCount] = await Promise.all([
this.models.workspace.get(workspaceId),
this.models.workspaceUser.count(workspaceId),
]);
if (!workspace) return toolError(`Workspace ${workspaceId} not found.`);
return toolText(
JSON.stringify({
workspace_id: workspaceId,
name: workspace.name,
member_count: memberCount,
})
);
},
});
tools.push(getWorkspaceInfo);
const getUsers = defineTool({
name: 'get_users',
title: 'Get Workspace Users',
description:
'List workspace members (id, name, email, avatar and role) so callers can mention or reference them.',
parser: z.object({
query: z.string().trim().min(1).max(255).optional(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
}),
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Filter by name or email.' },
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 },
},
additionalProperties: false,
},
execute: async ({ query, limit, offset }, options) => {
await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.Users.Read');
const aborted = abortIfNeeded(options.signal);
if (aborted) return aborted;
const pagination: PaginationInput = {
first: Math.min(limit ?? 20, 100),
offset: offset ?? 0,
};
const rows = query
? await this.models.workspaceUser.search(
workspaceId,
query,
pagination
)
: (
await this.models.workspaceUser.paginate(workspaceId, pagination)
)[0];
return toolText(
JSON.stringify({
users: rows.flatMap(row =>
row.status === 'Accepted' && row.user
? [
{
id: row.user.id,
name: row.user.name,
email: row.user.email,
avatar_url: row.user.avatarUrl,
role: WorkspaceRole[row.type] ?? 'Unknown',
},
]
: []
),
})
);
},
});
const getComments = defineTool({
name: 'get_comments',
title: 'Get Document Comments',
description:
'List comments (with replies) on a document, newest first. Requires the credential owner to be able to read comments on that document.',
parser: z.object({
docId: z.string(),
limit: z.number().int().min(1).max(100).optional(),
}),
inputSchema: {
type: 'object',
properties: {
docId: { type: 'string', description: 'The document ID.' },
limit: { type: 'integer', minimum: 1, maximum: 100 },
},
required: ['docId'],
additionalProperties: false,
},
execute: async ({ docId, limit }, options) => {
await this.ac.user(userId).doc({ workspaceId, docId }).can('Doc.Read');
const aborted = abortIfNeeded(options.signal);
if (aborted) return aborted;
const comments = await this.models.comment.list(workspaceId, docId, {
take: limit ?? 50,
});
return toolText(JSON.stringify({ comments }));
},
});
const createComment = defineTool({
name: 'create_comment',
title: 'Create Document Comment',
description:
'Add a plain-text comment on a document as the credential owner. Returns the created comment ID.',
parser: z.object({
docId: z.string(),
content: z.string().trim().min(1).max(5000),
}),
inputSchema: {
type: 'object',
properties: {
docId: { type: 'string', description: 'The document ID.' },
content: {
type: 'string',
description: 'Plain text comment body.',
},
},
required: ['docId', 'content'],
additionalProperties: false,
},
execute: async ({ docId, content }, options) => {
const accessible = await this.ac
.user(userId)
.doc({ workspaceId, docId })
.can('Doc.Comments.Create');
if (!accessible) return toolError(`Doc with id ${docId} not found.`);
const aborted = abortIfNeeded(options.signal);
if (aborted) return aborted;
try {
const comment = await this.models.comment.create({
workspaceId,
docId,
userId,
content: {
type: 'paragraph',
content: [{ type: 'text', text: content }],
},
});
return toolText(
JSON.stringify({
success: true,
comment_id: comment.id,
})
);
} catch (error) {
return toolError(
`Failed to create comment: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
},
});
if (accessMode === McpAccessMode.READ_WRITE) {
tools.push(createComment);
}
tools.push(getUsers, getComments);
return {
name: `AFFiNE MCP Server for Workspace ${workspaceId}`,
version: '1.0.1',
version: '1.1.0',
tools,
};
}

View File

@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import {
Args,
Field,
@@ -109,7 +108,7 @@ export class McpCredentialResolver {
@Query(() => Boolean)
mcpCredentialReadWriteAvailable() {
return env.dev || env.namespaces.canary;
return true;
}
@Mutation(() => RevealedMcpCredentialType)
@@ -117,13 +116,6 @@ export class McpCredentialResolver {
@CurrentUser() user: CurrentUser,
@Args('input') input: CreateMcpCredentialInput
) {
if (
input.accessMode === McpAccessMode.READ_WRITE &&
!env.dev &&
!env.namespaces.canary
) {
throw new BadRequestException('MCP write tools are not available');
}
await this.ac
.user(user.id)
.workspace(input.workspaceId)

View File

@@ -16,6 +16,7 @@ import {
NativeEmbeddingService,
} from './embedding';
import { CopilotEmbeddingRealtimeProvider } from './embedding/realtime';
import { ManagedCopilotProfileResolver } from './managed-profile.resolver';
import { WorkspaceMcpProvider } from './mcp/provider';
import { PromptService } from './prompt';
import { CopilotResolver, UserCopilotResolver } from './resolver';
@@ -112,6 +113,7 @@ export const COPILOT_RESOLVER_PROVIDERS = [
CopilotResolver,
UserCopilotResolver,
WorkspaceByokResolver,
ManagedCopilotProfileResolver,
];
export const COPILOT_JOB_PROVIDERS = [CopilotCronJobs];

View File

@@ -30,6 +30,7 @@ export enum CopilotProviderType {
Gemini = 'gemini',
GeminiVertex = 'geminiVertex',
OpenAI = 'openai',
OpenAICompatible = 'openaiCompatible',
}
export const CopilotProviderSchema = z.object({

View File

@@ -1448,6 +1448,33 @@ input ManageUserInput {
name: String
}
type ManagedProfileModelProbeCheckType {
operation: ByokProbeOperation!
status: ManagedProfileProbeStatusType!
}
type ManagedProfileModelProbeType {
checks: [ManagedProfileModelProbeCheckType!]!
modelId: String!
}
input ManagedProfileProbeCheckInput {
modelId: String!
operation: ByokProbeOperation!
}
type ManagedProfileProbeResultType {
connection: ManagedProfileProbeStatusType!
definitionFingerprint: String!
models: [ManagedProfileModelProbeType!]!
}
type ManagedProfileProbeStatusType {
errorKind: String
kind: ByokProbeStatusKind!
testedAt: DateTime
}
enum McpAccessMode {
READ_ONLY
READ_WRITE
@@ -1631,6 +1658,11 @@ type Mutation {
"""mention user in a doc"""
mentionUser(input: MentionInput!): ID!
previewLicense(license: Upload!): AdminLicensePreview!
"""
Test a server-managed copilot provider profile by dispatching real probe requests.
"""
probeManagedCopilotProfile(checks: [ManagedProfileProbeCheckInput!]!, profileId: ID!): ManagedProfileProbeResultType!
probeWorkspaceByokDraft(input: ProbeWorkspaceByokDraftInput!): WorkspaceByokProbeResultType!
probeWorkspaceByokProfile(input: ProbeWorkspaceByokProfileInput!): WorkspaceByokProbeResultType!
publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType!

View File

@@ -0,0 +1,20 @@
mutation probeManagedCopilotProfile($profileId: ID!, $checks: [ManagedProfileProbeCheckInput!]!) {
probeManagedCopilotProfile(profileId: $profileId, checks: $checks) {
connection {
kind
testedAt
errorKind
}
models {
modelId
checks {
operation
status {
kind
testedAt
errorKind
}
}
}
}
}

View File

@@ -512,6 +512,31 @@ export const listUsersQuery = {
}`,
};
export const probeManagedCopilotProfileMutation = {
id: 'probeManagedCopilotProfileMutation' as const,
op: 'probeManagedCopilotProfile',
query: `mutation probeManagedCopilotProfile($profileId: ID!, $checks: [ManagedProfileProbeCheckInput!]!) {
probeManagedCopilotProfile(profileId: $profileId, checks: $checks) {
connection {
kind
testedAt
errorKind
}
models {
modelId
checks {
operation
status {
kind
testedAt
errorKind
}
}
}
}
}`,
};
export const rotateAuthSigningKeyMutation = {
id: 'rotateAuthSigningKeyMutation' as const,
op: 'rotateAuthSigningKey',

View File

@@ -1641,6 +1641,36 @@ export interface ManageUserInput {
name?: InputMaybe<Scalars['String']['input']>;
}
export interface ManagedProfileModelProbeCheckType {
__typename?: 'ManagedProfileModelProbeCheckType';
operation: ByokProbeOperation;
status: ManagedProfileProbeStatusType;
}
export interface ManagedProfileModelProbeType {
__typename?: 'ManagedProfileModelProbeType';
checks: Array<ManagedProfileModelProbeCheckType>;
modelId: Scalars['String']['output'];
}
export interface ManagedProfileProbeCheckInput {
modelId: Scalars['String']['input'];
operation: ByokProbeOperation;
}
export interface ManagedProfileProbeResultType {
__typename?: 'ManagedProfileProbeResultType';
connection: ManagedProfileProbeStatusType;
models: Array<ManagedProfileModelProbeType>;
}
export interface ManagedProfileProbeStatusType {
__typename?: 'ManagedProfileProbeStatusType';
errorKind: Maybe<Scalars['String']['output']>;
kind: ByokProbeStatusKind;
testedAt: Maybe<Scalars['DateTime']['output']>;
}
export enum McpAccessMode {
READ_ONLY = 'READ_ONLY',
READ_WRITE = 'READ_WRITE',
@@ -1813,6 +1843,8 @@ export interface Mutation {
/** mention user in a doc */
mentionUser: Scalars['ID']['output'];
previewLicense: AdminLicensePreview;
/** Test a server-managed copilot provider profile by dispatching real probe requests. */
probeManagedCopilotProfile: ManagedProfileProbeResultType;
probeWorkspaceByokDraft: WorkspaceByokProbeResultType;
probeWorkspaceByokProfile: WorkspaceByokProbeResultType;
publishDoc: DocType;
@@ -2115,6 +2147,11 @@ export interface MutationPreviewLicenseArgs {
license: Scalars['Upload']['input'];
}
export interface MutationProbeManagedCopilotProfileArgs {
checks: Array<ManagedProfileProbeCheckInput>;
profileId: Scalars['ID']['input'];
}
export interface MutationProbeWorkspaceByokDraftArgs {
input: ProbeWorkspaceByokDraftInput;
}
@@ -4278,6 +4315,38 @@ export type ListUsersQuery = {
}>;
};
export type ProbeManagedCopilotProfileMutationVariables = Exact<{
profileId: Scalars['ID']['input'];
checks: Array<ManagedProfileProbeCheckInput> | ManagedProfileProbeCheckInput;
}>;
export type ProbeManagedCopilotProfileMutation = {
__typename?: 'Mutation';
probeManagedCopilotProfile: {
__typename?: 'ManagedProfileProbeResultType';
connection: {
__typename?: 'ManagedProfileProbeStatusType';
kind: ByokProbeStatusKind;
testedAt: string | null;
errorKind: string | null;
};
models: Array<{
__typename?: 'ManagedProfileModelProbeType';
modelId: string;
checks: Array<{
__typename?: 'ManagedProfileModelProbeCheckType';
operation: ByokProbeOperation;
status: {
__typename?: 'ManagedProfileProbeStatusType';
kind: ByokProbeStatusKind;
testedAt: string | null;
errorKind: string | null;
};
}>;
}>;
};
};
export type RotateAuthSigningKeyMutationVariables = Exact<{
expectedActiveKeyId: Scalars['String']['input'];
}>;
@@ -8125,6 +8194,11 @@ export type Mutations =
variables: ImportUsersMutationVariables;
response: ImportUsersMutation;
}
| {
name: 'probeManagedCopilotProfileMutation';
variables: ProbeManagedCopilotProfileMutationVariables;
response: ProbeManagedCopilotProfileMutation;
}
| {
name: 'rotateAuthSigningKeyMutation';
variables: RotateAuthSigningKeyMutationVariables;

View File

@@ -389,6 +389,10 @@
"byok.allowPrivateEndpoint": {
"type": "Boolean",
"desc": "Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network."
},
"providers.profiles": {
"type": "Array",
"desc": "The profile list for copilot providers."
}
},
"indexer": {

View File

@@ -4,6 +4,7 @@ import type { ComponentType } from 'react';
import CONFIG_DESCRIPTORS from '../../config.json';
import type { ConfigInputProps } from './config-input-row';
import { AuthSigningKeys } from './operations/auth-signing-keys';
import { ProbeManagedProfiles } from './operations/probe-managed-profiles';
import { SendTestEmail } from './operations/send-test-email';
export type ConfigType = 'String' | 'Number' | 'Boolean' | 'JSON' | 'Enum';
@@ -163,6 +164,18 @@ export const KNOWN_CONFIG_GROUPS = [
},
],
} as ConfigGroup<'copilot'>,
{
name: 'AI Providers',
module: 'copilot',
fields: [
{
key: 'providers.profiles',
desc: 'Server-managed AI provider profiles. Edit as JSON, then use Test to verify connectivity. Example: [{"id":"local-vllm","provider":"openaiCompatible","name":"Local vLLM","enabled":true,"config":{"baseURL":"http://localhost:8000/v1","apiKey":"none","dialect":"chat_completions"},"models":["qwen2.5-7b-instruct"]}].',
type: 'JSON',
},
],
operations: [ProbeManagedProfiles],
} as ConfigGroup<'copilot'>,
{
name: 'Indexer',
module: 'indexer',

View File

@@ -0,0 +1,144 @@
/**
* @vitest-environment happy-dom
*/
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const triggerMock = vi.fn();
vi.mock('@affine/admin/use-mutation', () => ({
useMutation: () => ({ trigger: triggerMock, isMutating: false }),
}));
vi.mock('@affine/component', () => ({
notify: { success: vi.fn(), error: vi.fn() },
}));
import { ProbeManagedProfiles } from './probe-managed-profiles';
describe('ProbeManagedProfiles', () => {
beforeEach(() => {
triggerMock.mockReset();
});
afterEach(() => {
cleanup();
});
test('renders nothing when no managed profiles configured', () => {
const { container } = render(
<ProbeManagedProfiles appConfig={{ copilot: {} }} />
);
expect(container.textContent).toBe('');
});
test('probes each enabled profile model with declared capabilities', async () => {
triggerMock.mockResolvedValue({
probeManagedCopilotProfile: {
connection: { kind: 'verified', errorKind: null },
models: [
{
modelId: 'qwen',
checks: [
{
operation: 'chat',
status: { kind: 'verified', errorKind: null },
},
],
},
],
},
});
render(
<ProbeManagedProfiles
appConfig={{
copilot: {
'providers.profiles': [
{
id: 'local-vllm',
name: 'Local vLLM',
enabled: true,
models: [{ id: 'qwen', capabilities: ['chat', 'tools'] }],
},
{
id: 'disabled-profile',
enabled: false,
models: ['m'],
},
],
},
}}
/>
);
const button = screen.getByRole('button', { name: 'Test Local vLLM' });
expect(
screen.queryByRole('button', { name: /disabled-profile/ })
).toBeNull();
fireEvent.click(button);
await waitFor(() => {
expect(triggerMock).toHaveBeenCalled();
});
expect(triggerMock).toHaveBeenCalledWith({
profileId: 'local-vllm',
checks: [
{ modelId: 'qwen', operation: 'chat' },
{ modelId: 'qwen', operation: 'tool_calling' },
],
});
await waitFor(() => {
expect(screen.getByText(/Connection:/)).toBeDefined();
expect(screen.getByText(/qwen: chat verified/)).toBeDefined();
});
});
test('defaults to chat check when capabilities are undeclared', async () => {
triggerMock.mockResolvedValue({
probeManagedCopilotProfile: {
connection: { kind: 'failed', errorKind: 'http_401' },
models: [
{
modelId: 'm1',
checks: [
{
operation: 'chat',
status: { kind: 'failed', errorKind: 'http_401' },
},
],
},
],
},
});
render(
<ProbeManagedProfiles
appConfig={{
copilot: {
'providers.profiles': [{ id: 'p1', models: ['m1'] }],
},
}}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Test p1' }));
await waitFor(() => {
expect(triggerMock).toHaveBeenCalledWith({
profileId: 'p1',
checks: [{ modelId: 'm1', operation: 'chat' }],
});
});
await waitFor(() => {
expect(screen.getByText(/failed \(http_401\)/)).toBeDefined();
});
});
});

View File

@@ -0,0 +1,171 @@
import { Button } from '@affine/admin/components/ui/button';
import { useMutation } from '@affine/admin/use-mutation';
import { notify } from '@affine/component';
import {
type ByokProbeOperation,
type ByokProbeStatusKind,
probeManagedCopilotProfileMutation,
} from '@affine/graphql';
import { useCallback, useMemo, useState } from 'react';
import type { AppConfig } from '../config';
type ProfileLike = {
id?: string;
name?: string;
enabled?: boolean;
models?: Array<string | { id: string; capabilities?: string[] }>;
};
const CAPABILITY_OPERATION: Record<string, ByokProbeOperation> = {
chat: 'chat',
structured: 'structured',
tools: 'tool_calling',
vision: 'vision',
embedding: 'embedding',
rerank: 'rerank',
image: 'image',
};
function checksForModel(
model: NonNullable<ProfileLike['models']>[number]
): Array<{
modelId: string;
operation: ByokProbeOperation;
}> {
const modelId = typeof model === 'string' ? model : model.id;
const capabilities =
typeof model === 'string' ? [] : (model.capabilities ?? []);
if (capabilities.length === 0) {
// Undeclared capabilities: probe basic chat as the default smoke test.
return [{ modelId, operation: 'chat' }];
}
return capabilities
.map(
(capability: string): ByokProbeOperation | undefined =>
CAPABILITY_OPERATION[capability]
)
.filter((operation): operation is ByokProbeOperation => Boolean(operation))
.map(operation => ({ modelId, operation }));
}
const STATUS_LABEL: Record<ByokProbeStatusKind, string> = {
verified: 'verified',
failed: 'failed',
not_tested: 'not tested',
};
export function ProbeManagedProfiles({ appConfig }: { appConfig: AppConfig }) {
const { trigger, isMutating } = useMutation({
mutation: probeManagedCopilotProfileMutation,
});
const [results, setResults] = useState<{
profileId: string;
connection: { kind: string; errorKind?: string | null };
models: Array<{
modelId: string;
checks: Array<{
operation: string;
status: { kind: string; errorKind?: string | null };
}>;
}>;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const profiles = useMemo(() => {
const raw = appConfig?.copilot?.['providers.profiles'];
return Array.isArray(raw) ? (raw as ProfileLike[]) : [];
}, [appConfig]);
const onTest = useCallback(
async (profile: ProfileLike) => {
if (!profile.id) {
return;
}
setError(null);
setResults(null);
const checks = (profile.models ?? []).flatMap(checksForModel);
try {
const result = await trigger({
profileId: profile.id,
checks: checks.length > 0 ? checks : [],
});
setResults({
profileId: profile.id,
...result.probeManagedCopilotProfile,
});
notify.success({
title: `Tested ${profile.name || profile.id}`,
message: `Connection ${result.probeManagedCopilotProfile.connection.kind}.`,
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[trigger]
);
if (profiles.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-3">
<div className="text-sm font-semibold leading-6 text-foreground">
Test provider connectivity
</div>
<div className="flex flex-col gap-2">
{profiles.map(profile =>
profile.id && profile.enabled !== false ? (
<div key={profile.id} className="flex items-center gap-3">
<Button
type="button"
variant="outline"
className="h-8"
disabled={isMutating}
onClick={() => void onTest(profile)}
>
Test {profile.name || profile.id}
</Button>
</div>
) : null
)}
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{results && (
<div className="rounded-md border border-border/60 p-3 text-sm">
<div>
Connection:{' '}
<span
className={
results.connection.kind === 'verified'
? 'text-green-600'
: 'text-destructive'
}
>
{results.connection.kind}
</span>
{results.connection.errorKind
? ` (${results.connection.errorKind})`
: ''}
</div>
{results.models.map(model => (
<div key={model.modelId} className="mt-1 pl-3">
{model.modelId}:{' '}
{model.checks
.map(
check =>
`${check.operation} ${STATUS_LABEL[check.status.kind] ?? check.status.kind}${
check.status.errorKind
? ` (${check.status.errorKind})`
: ''
}`
)
.join(', ')}
</div>
))}
</div>
)}
</div>
);
}