Compare commits
3 Commits
canary
...
mcp-upgrad
| Author | SHA1 | Date | |
|---|---|---|---|
| ff599ac7f1 | |||
| c256de50aa | |||
| ae2d0be2ea |
@@ -11,6 +11,9 @@
|
||||
/.yarn/unplugged
|
||||
/.yarn/install-state.gz
|
||||
/.pnp.*
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/*.tsbuildinfo
|
||||
|
||||
# Test artifacts
|
||||
/test-results
|
||||
|
||||
4
.gitea-deploy
Normal file
4
.gitea-deploy
Normal file
@@ -0,0 +1,4 @@
|
||||
DOMAIN="gitea.luulam.dev"
|
||||
REPO_PATH="luulam/affine"
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
DOCKERFILE="./Dockerfile.all-in-one"
|
||||
173
Dockerfile.all-in-one
Normal file
173
Dockerfile.all-in-one
Normal file
@@ -0,0 +1,173 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# =============================================================================
|
||||
# AFFiNE all-in-one Docker build (self-contained, no prebuilt artifacts needed)
|
||||
#
|
||||
# Stages:
|
||||
# 1. frontend - builds @affine/web + @affine/admin + @affine/mobile dists
|
||||
# 2. native - cross-compiles @affine/server-native (Rust) per TARGETARCH
|
||||
# 3. server-dist - bundles @affine/server (rspack) -> dist/main.js
|
||||
# 4. deps - installs production node_modules (multi-arch aware)
|
||||
# 5. runtime - assembles final image, mirrors .github/deployment/node/Dockerfile
|
||||
#
|
||||
# Build: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.all-in-one .
|
||||
# =============================================================================
|
||||
|
||||
# --- Shared base args ---------------------------------------------------------
|
||||
FROM node:22-bookworm-slim AS base
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends jq python3 make g++ && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything the yarn install / builds need (respects .dockerignore).
|
||||
COPY . /app/
|
||||
|
||||
# Yarn 4 via corepack; keep scripts off like CI (.yarnrc.yml enableScripts=false)
|
||||
RUN corepack enable && corepack prepare yarn@4.18.0 --activate
|
||||
|
||||
# Full dependency install for build stages.
|
||||
# Prisma postinstall generates client for the BUILD platform only; the runtime
|
||||
# stage re-generates it for the target platform (see `deps` stage).
|
||||
RUN yarn install --immutable
|
||||
|
||||
# =============================================================================
|
||||
# Stage 1: frontend dists (web + admin + mobile)
|
||||
# =============================================================================
|
||||
FROM base AS frontend
|
||||
|
||||
# Self-hosted bundle: force publicPath '/' so assets are served from this
|
||||
# container instead of affineassets CDN (html-plugin getPublicPath()).
|
||||
ENV BUILD_TYPE=stable \
|
||||
PUBLIC_PATH=/ \
|
||||
NODE_ENV=production \
|
||||
GITHUB_SHA=dockerselfhost
|
||||
|
||||
RUN yarn affine @affine/web build && \
|
||||
yarn affine @affine/admin build && \
|
||||
yarn affine @affine/mobile build
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Rust native module (@affine/server-native), per target arch
|
||||
# =============================================================================
|
||||
FROM base AS native
|
||||
ARG TARGETARCH
|
||||
|
||||
# Rust toolchain (minimal profile; extra targets added per-arch below).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:${PATH}
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain 1.97.1 --profile minimal && \
|
||||
cargo --version && rustc --version
|
||||
ENV CARGO_PROFILE_RELEASE_LTO=thin \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 \
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
|
||||
CARGO_PROFILE_RELEASE_STRIP=symbols
|
||||
|
||||
|
||||
WORKDIR /app/packages/backend/native
|
||||
|
||||
# Cross-compile with Debian's cross-GCC instead of napi-rs' bundled GCC 4.8.5:
|
||||
# gcc 4.8.5 does not define __ARM_ARCH for aarch64, breaking aws-lc-sys asm.
|
||||
RUN if [ "$TARGETARCH" = "arm64" ]; then \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rustup target add aarch64-unknown-linux-gnu && \
|
||||
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
|
||||
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
|
||||
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||
yarn build --target aarch64-unknown-linux-gnu && \
|
||||
# index.js resolves arch-specific names; rspack bundles all of them.
|
||||
cp server-native.node server-native.arm64.node && \
|
||||
cp server-native.node server-native.x64.node && \
|
||||
cp server-native.node server-native.armv7.node; \
|
||||
else \
|
||||
yarn build --target x86_64-unknown-linux-gnu && \
|
||||
# Only server-native.node is present from napi; provide arch aliases so
|
||||
# rspack can resolve index.js's arch-specific requires (docker-clean
|
||||
# prunes the wrong-arch binary at runtime anyway).
|
||||
cp server-native.node server-native.x64.node && \
|
||||
cp server-native.node server-native.arm64.node && \
|
||||
cp server-native.node server-native.armv7.node; \
|
||||
fi && \
|
||||
ls -la server-native*.node
|
||||
|
||||
# =============================================================================
|
||||
# Stage 3: server bundle (dist/main.js)
|
||||
# =============================================================================
|
||||
FROM base AS server-dist
|
||||
COPY --from=native /app/packages/backend/native/*.node /app/packages/backend/native/
|
||||
|
||||
RUN yarn workspace @affine/server build
|
||||
|
||||
# =============================================================================
|
||||
# Stage 4: production node_modules (per target arch)
|
||||
# Reuse base (full install already done & cached), then prune to server prod
|
||||
# deps. supportedArchitectures covers both cpus so optional native deps for
|
||||
# either arch get fetched.
|
||||
# =============================================================================
|
||||
FROM base AS deps
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN yarn config set --json supportedArchitectures.cpu "[\"x64\", \"arm64\"]" && \
|
||||
yarn config set --json supportedArchitectures.libc "[\"glibc\"]"
|
||||
|
||||
# Re-install with both cpu targets so the image works on amd64+arm64 builds.
|
||||
RUN yarn install --immutable && \
|
||||
yarn workspaces focus @affine/server @types/affine__env --production
|
||||
|
||||
# Regenerate prisma client engines for this build platform's node_modules
|
||||
RUN cd packages/backend/server && yarn prisma generate
|
||||
|
||||
# Move to server dir layout expected by docker-clean.mjs & runtime stage
|
||||
RUN mv /app/node_modules /app/packages/backend/server/node_modules
|
||||
|
||||
# =============================================================================
|
||||
# Stage 5: runtime — mirror .github/deployment/node/Dockerfile layout
|
||||
# =============================================================================
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/packages/backend/server/node_modules /app/node_modules
|
||||
COPY --from=server-dist /app/packages/backend/server/dist /app/dist
|
||||
COPY --from=server-dist /app/packages/backend/server/scripts /app/scripts
|
||||
COPY --from=frontend /app/packages/frontend/apps/web/dist /app/static
|
||||
COPY --from=frontend /app/packages/frontend/admin/dist /app/static/admin
|
||||
COPY --from=frontend /app/packages/frontend/apps/mobile/dist /app/static/mobile
|
||||
# Mirror upstream layout: /app IS the server package dir — the selfhost
|
||||
# predeploy job runs `yarn prisma migrate deploy` + `yarn cli` from here.
|
||||
COPY --from=base /app/packages/backend/server/package.json /app/package.json
|
||||
COPY --from=base /app/packages/backend/server/schema.prisma /app/schema.prisma
|
||||
COPY --from=base /app/packages/backend/server/migrations /app/migrations
|
||||
|
||||
# Upstream installs these in the final image (jemalloc for ENV below,
|
||||
# openssl for prisma engines).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends openssl libjemalloc2 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Native binding for this target arch. node-loader emitted
|
||||
# `require('./server-native.node')` inside dist/main.js — the binary must sit
|
||||
# next to it.
|
||||
COPY --from=native /app/packages/backend/native/server-native.node /app/dist/server-native.node
|
||||
|
||||
ENV LD_PRELOAD=libjemalloc.so.2
|
||||
|
||||
|
||||
# Same pruning as upstream: sourcemaps, wrong-arch natives/prisma engines,
|
||||
# dedupe static files.
|
||||
RUN AFFINE_DOCKER_CLEAN=1 \
|
||||
TARGETARCH="$( [ "$TARGETARCH" = "amd64" ] && echo amd64 || echo arm64 )" \
|
||||
node ./scripts/docker-clean.mjs
|
||||
|
||||
EXPOSE 3010
|
||||
|
||||
CMD ["node", "./dist/main.js"]
|
||||
60
docker-compose.gitea.yml
Normal file
60
docker-compose.gitea.yml
Normal 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:
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
191
scripts/deploy-gitea.sh
Executable file
191
scripts/deploy-gitea.sh
Executable file
@@ -0,0 +1,191 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# Gitea Docker Deploy Script
|
||||
# Build multi-platform Docker image and push to Gitea container registry
|
||||
# =============================================================================
|
||||
|
||||
# --- Load config from file if exists ---
|
||||
CONFIG_FILE=".gitea-deploy"
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
source "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# --- Configuration ---
|
||||
# Priority: 1) environment variables, 2) .gitea-deploy file, 3) auto-detect
|
||||
DOMAIN="${GITEA_DOMAIN:-$DOMAIN}"
|
||||
REPO_PATH="${GITEA_REPO:-$REPO_PATH}"
|
||||
PLATFORMS="${DOCKER_PLATFORMS:-${PLATFORMS:-linux/amd64,linux/arm64}}"
|
||||
DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
|
||||
|
||||
# --- Output colors ---
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# --- Auto-detect from git remote ---
|
||||
detect_gitea_info() {
|
||||
local remote_url
|
||||
local remote_name="${GIT_REMOTE:-}"
|
||||
|
||||
# Priority: 1) GIT_REMOTE env, 2) remote "gitea", 3) remote "origin"
|
||||
if [[ -n "$remote_name" ]]; then
|
||||
remote_url=$(git remote get-url "$remote_name" 2>/dev/null || echo "")
|
||||
elif git remote get-url gitea &>/dev/null; then
|
||||
remote_name="gitea"
|
||||
remote_url=$(git remote get-url gitea)
|
||||
log_info "Using remote 'gitea'"
|
||||
else
|
||||
remote_name="origin"
|
||||
remote_url=$(git remote get-url origin 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
log_error "Git remote 'origin' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse URL: support multiple formats
|
||||
# SSH short: git@gitea.luulam.dev:luulam/9router.git
|
||||
# SSH full: ssh://git@192.168.1.10:2222/luulam/9router.git
|
||||
# HTTPS: https://gitea.luulam.dev/luulam/9router.git
|
||||
|
||||
local detected_domain=""
|
||||
local detected_repo=""
|
||||
|
||||
if [[ "$remote_url" =~ ^ssh:// ]]; then
|
||||
# SSH full format: ssh://git@host:port/path.git
|
||||
detected_domain=$(echo "$remote_url" | sed 's|ssh://[^@]*@\([^:/]*\).*|\1|')
|
||||
detected_repo=$(echo "$remote_url" | sed 's|ssh://[^/]*/\(.*\)\.git|\1|' | sed 's|\.git$||')
|
||||
elif [[ "$remote_url" =~ ^git@ ]]; then
|
||||
# SSH short format: git@host:path.git
|
||||
detected_domain=$(echo "$remote_url" | sed 's/git@\([^:]*\):.*/\1/')
|
||||
detected_repo=$(echo "$remote_url" | sed 's/git@[^:]*:\(.*\)\.git/\1/' | sed 's/\.git$//')
|
||||
elif [[ "$remote_url" =~ ^https?:// ]]; then
|
||||
# HTTPS format
|
||||
detected_domain=$(echo "$remote_url" | sed 's|https\?://\([^/]*\)/.*|\1|')
|
||||
detected_repo=$(echo "$remote_url" | sed 's|https\?://[^/]*/\(.*\)\.git|\1|' | sed 's/\.git$//')
|
||||
else
|
||||
log_error "Unrecognized git remote format: $remote_url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use detected values if not configured
|
||||
REPO_PATH="${REPO_PATH:-$detected_repo}"
|
||||
|
||||
# Check if domain is IP -> need to configure DOMAIN
|
||||
if [[ -z "$DOMAIN" ]]; then
|
||||
if [[ "$detected_domain" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
log_error "Git remote points to IP ($detected_domain), not domain."
|
||||
log_error "Please create .gitea-deploy file with:"
|
||||
echo ""
|
||||
echo " DOMAIN=\"gitea.luulam.dev\""
|
||||
echo " REPO_PATH=\"$detected_repo\""
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
DOMAIN="$detected_domain"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Check prerequisites ---
|
||||
check_prerequisites() {
|
||||
log_info "Checking prerequisites..."
|
||||
|
||||
# Dockerfile
|
||||
if [[ ! -f "$DOCKERFILE" ]]; then
|
||||
log_error "$DOCKERFILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Docker daemon
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
log_error "Docker daemon not running. Please start Docker Desktop."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Docker buildx
|
||||
if ! docker buildx version > /dev/null 2>&1; then
|
||||
log_error "Docker Buildx not installed."
|
||||
log_info "Install with: docker buildx install"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if builder supports multi-platform
|
||||
local builder_platforms
|
||||
builder_platforms=$(docker buildx inspect --bootstrap 2>/dev/null | grep -i "platforms" || echo "")
|
||||
if [[ -z "$builder_platforms" ]]; then
|
||||
log_warn "Creating new multi-platform builder..."
|
||||
docker buildx create --use --name multiarch-builder --driver docker-container --bootstrap
|
||||
fi
|
||||
|
||||
log_info "All prerequisites ready!"
|
||||
}
|
||||
|
||||
# --- Build and Push ---
|
||||
build_and_push() {
|
||||
local git_rev
|
||||
git_rev=$(git rev-parse --short HEAD)
|
||||
|
||||
local image_base="$DOMAIN/$REPO_PATH"
|
||||
local tag_rev="$image_base:$git_rev"
|
||||
local tag_latest="$image_base:latest"
|
||||
|
||||
echo ""
|
||||
log_info "=========================================="
|
||||
log_info "Build Docker Image"
|
||||
log_info "=========================================="
|
||||
log_info "Registry: $DOMAIN"
|
||||
log_info "Repository: $REPO_PATH"
|
||||
log_info "Platforms: $PLATFORMS"
|
||||
log_info "Tags: $git_rev, latest"
|
||||
log_info "Dockerfile: $DOCKERFILE"
|
||||
echo ""
|
||||
|
||||
# Build and push
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--tag "$tag_rev" \
|
||||
--tag "$tag_latest" \
|
||||
--file "$DOCKERFILE" \
|
||||
--push .
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo ""
|
||||
log_info "=========================================="
|
||||
log_info "Deploy successful!"
|
||||
log_info "=========================================="
|
||||
log_info "Image: $tag_rev"
|
||||
log_info "Image: $tag_latest"
|
||||
echo ""
|
||||
log_info "Pull image:"
|
||||
echo " docker pull $tag_latest"
|
||||
echo ""
|
||||
else
|
||||
log_error "Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
main() {
|
||||
echo ""
|
||||
log_info "Gitea Docker Deploy"
|
||||
echo ""
|
||||
|
||||
# Detect info if not configured
|
||||
if [[ -z "$DOMAIN" ]] || [[ -z "$REPO_PATH" ]]; then
|
||||
detect_gitea_info
|
||||
fi
|
||||
|
||||
check_prerequisites
|
||||
build_and_push
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user