release: prepare v0.1.1 unified context for GitHub

This commit is contained in:
ilya-bov
2026-03-03 14:33:45 +03:00
parent 96f065595d
commit 789c63da9d
15 changed files with 595 additions and 25 deletions

26
.github/release_template.md vendored Normal file
View File

@@ -0,0 +1,26 @@
## Eggent v{{VERSION}} - {{NAME}}
One-line summary of what changed and why it matters.
### Highlights
- Highlight 1
- Highlight 2
- Highlight 3
### Platform Coverage
- Dashboard:
- API:
- Integrations:
### Upgrade Notes
- Compatibility:
- Migration:
- Operational changes:
### Links
- Full notes: `docs/releases/{{FILE}}`
- README: `README.md`

3
.gitignore vendored
View File

@@ -40,3 +40,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# python
__pycache__/
*.py[cod]

16
CHANGELOG.md Normal file
View File

@@ -0,0 +1,16 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.1.1] - 2026-03-03
### Added
- `PUT /api/projects/[id]/mcp` endpoint for saving raw MCP config content.
- Inline MCP JSON editor with save/reset in `Dashboard -> MCP`.
- Inline MCP JSON editor with save/reset in project details context panel.
- Editable project instructions with save/reset in project details.
- Release documentation set in `docs/releases/`.
### Changed
- MCP content validation and normalization before writing `.meta/mcp/servers.json`.
- Package/app health version updated to `0.1.1`.

View File

@@ -20,6 +20,12 @@ Built-in platform capabilities:
The app runs as a Next.js service and stores runtime state on disk (`./data`).
## Releases
- Latest release snapshot: [0.1.1 - Unified Context](./docs/releases/0.1.1-unified-context.md)
- GitHub release body (ready to paste): [v0.1.1](./docs/releases/github-v0.1.1.md)
- Release archive: [docs/releases/README.md](./docs/releases/README.md)
## Contributing and Support
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)

View File

@@ -0,0 +1,82 @@
# Eggent 0.1.1 - Unified Context
Date: 2026-03-03
Type: Generalized release snapshot
## Release Name
`Unified Context`
This release combines current platform capabilities into one coherent milestone: project-centric agent work, persistent memory and knowledge, MCP/skills extensibility, scheduled automation, and external integrations.
## What Is Included
### 1) Workspace and Projects
- Multi-project workspace with chat isolation by project.
- Project profile: name, description, instructions, memory mode (`isolated`/`global`).
- Full project details page with project context, cron jobs, and knowledge base in one place.
- First-run onboarding flow: credentials, first project, model setup, Telegram, and starter skills.
### 2) Agent Runtime and Tooling
- Agent chat loop with tool calls and persistent chat history.
- Built-in tool families: code execution, memory operations, knowledge search, web search, cron automation, subordinate-agent call support.
- Per-project work directory and context-aware routing.
### 3) Memory and Knowledge
- Persistent vector memory with search and deletion UI.
- Project knowledge ingestion via file upload.
- Supported ingestion formats: `txt`, `md`, `json`, `csv`, `pdf`, `docx`, `xlsx`, `xls`, images (`png`, `jpg`, `jpeg`, `gif`, `bmp`, `webp`).
- Knowledge chunk inspection and memory browsing in dashboard.
### 4) Skills Platform
- Bundled skills catalog with per-project installation.
- Installed project skills inspection with full `SKILL.md` view.
- Current bundled catalog size: `38` skills.
- Bundled skills included: `bear-notes`, `bluebubbles`, `camsnap`, `canvas`, `coding-agent`, `discord`, `excalidraw`, `gemini`, `gh-issues`, `gifgrep`, `github`, `healthcheck`, `imsg`, `last30days`, `mcporter`, `model-usage`, `nano-banana-pro`, `nano-pdf`, `notion`, `obsidian`, `openai-image-gen`, `openai-whisper`, `openai-whisper-api`, `openhue`, `oracle`, `ordercli`, `playwright-cli`, `remotion`, `session-logs`, `skill-creator`, `slack`, `summarize`, `things-mac`, `tmux`, `trello`, `video-frames`, `voice-call`, `weather`.
### 5) MCP Integration
- MCP configuration storage per project at `.meta/mcp/servers.json`.
- MCP server normalization for Cursor-style `mcpServers` and legacy `servers` formats.
- MCP browser/editor page for all projects.
- Project details context panel with inline MCP editing.
### 6) Cron Automation
- Per-project cron jobs with three schedule modes: one-time (`at`), interval (`every`), cron expression (`cron`).
- Manual run, enable/disable, delete, and run history inspection.
- Optional Telegram delivery target and per-job timeout.
### 7) External API and Session Context
- External message endpoint: `POST /api/external/message`.
- Project context resolution across messages and sessions.
- External API token management with rotation UI.
### 8) Messenger Integration
- Telegram integration management in dashboard.
- Webhook setup/reconnect/disconnect flows.
- Access-code gating and allowlist management.
- Telegram command set: `/start`, `/help`, `/code <access_code>`, `/new`.
### 9) Settings, Models, and Security
- Model configuration wizards for chat and embeddings.
- Provider support: OpenAI, Anthropic, Google, OpenRouter, Ollama, custom.
- Code execution controls (enable/timeout/max output).
- Memory and search provider controls (Tavily, SearXNG, disabled).
- Dashboard credentials management (`/api/auth/credentials`).
### 10) Operations and Delivery
- Install modes: one-command installer, local production, Docker production, manual.
- Health endpoint: `GET /api/health`.
- Realtime UI sync endpoint and disk-first data persistence under `./data`.
## New in 0.1.1
- Added `PUT /api/projects/[id]/mcp` for saving raw MCP config content.
- Added MCP raw JSON editing with save/reset on `Dashboard -> MCP` and project details context section.
- Added editable project instructions with save/reset on project details page.
- Added MCP content validation and normalization before writing `servers.json`.
- Bumped package version to `0.1.1`.
- Updated health endpoint version response to `0.1.1`.
## Coverage Checklist (No Module Left Out)
- Dashboard pages included in this release: `chat`, `projects`, `memory`, `skills`, `mcp`, `cron`, `settings`, `api`, `messengers`.
- API surface included in this release: auth, chat, projects, skills, MCP, memory, knowledge, cron, external API, Telegram integration, files, models, settings, health, events.

7
docs/releases/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Releases
This directory contains release summaries and publish-ready notes.
| Version | Name | Date | Notes |
| --- | --- | --- | --- |
| `0.1.1` | Unified Context | 2026-03-03 | [Full snapshot](./0.1.1-unified-context.md), [GitHub body](./github-v0.1.1.md) |

View File

@@ -0,0 +1,29 @@
## Eggent v0.1.1 - Unified Context
Generalized release that consolidates the full platform surface into one project-centric workflow: chat + tools, memory + knowledge, skills + MCP, cron automation, and external messaging integrations.
### Highlights
- Added raw MCP config save endpoint: `PUT /api/projects/[id]/mcp`.
- Added direct `servers.json` editing with save/reset in `Dashboard -> MCP`.
- Added same MCP editing controls in project details context panel.
- Added editable project instructions with save/reset in project details.
- Added MCP content validation/normalization before writing `.meta/mcp/servers.json`.
- Version bump to `0.1.1` across package metadata and health response.
### Platform Coverage
- Dashboard modules: `chat`, `projects`, `memory`, `skills`, `mcp`, `cron`, `settings`, `api`, `messengers`.
- API modules: auth, chat, projects, skills, MCP, memory, knowledge, cron, external API, Telegram integration, files, models, settings, health, realtime events.
- Bundled skills catalog: `38` skills available for per-project install.
### Upgrade Notes
- Existing MCP configs continue to work (Cursor `mcpServers` and legacy `servers` are both supported).
- `GET /api/health` now reports version `0.1.1`.
- No migration step required for existing `data/` projects.
### Links
- Full release snapshot: `docs/releases/0.1.1-unified-context.md`
- Installation and update guide: `README.md`

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "design-vibe",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "design-vibe",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.37",
"@ai-sdk/google": "^3.0.21",

View File

@@ -1,6 +1,6 @@
{
"name": "design-vibe",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -2,6 +2,6 @@ export async function GET() {
return Response.json({
status: "ok",
timestamp: new Date().toISOString(),
version: "0.1.0",
version: "0.1.1",
});
}

View File

@@ -4,6 +4,7 @@ import {
getProject,
getProjectMcpServersPath,
loadProjectMcpServers,
saveProjectMcpServersContent,
} from "@/lib/storage/project-store";
function isNotFoundError(error: unknown): boolean {
@@ -41,3 +42,42 @@ export async function GET(
);
}
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const project = await getProject(id);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
}
const content =
body && typeof body === "object" && "content" in body
? (body as { content?: unknown }).content
: undefined;
if (typeof content !== "string") {
return NextResponse.json(
{ error: 'Field "content" must be a string.' },
{ status: 400 }
);
}
const result = await saveProjectMcpServersContent(id, content);
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
return NextResponse.json({
content: result.content,
servers: result.servers,
});
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Globe, Loader2, Terminal, Wrench } from "lucide-react";
import { useAppStore } from "@/store/app-store";
@@ -74,14 +75,19 @@ function normalizeServers(input: unknown): McpServerItem[] {
return servers;
}
const EMPTY_MCP_JSON = JSON.stringify({ mcpServers: {} }, null, 2);
export default function McpPage() {
const { projects, setProjects, activeProjectId } = useAppStore();
const [selectedProjectId, setSelectedProjectId] = useState("");
const [servers, setServers] = useState<McpServerItem[]>([]);
const [rawContent, setRawContent] = useState<string | null>(null);
const [draftContent, setDraftContent] = useState(EMPTY_MCP_JSON);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [projectsLoading, setProjectsLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusTone, setStatusTone] = useState<"success" | "error" | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
@@ -131,7 +137,9 @@ export default function McpPage() {
if (!projectId) {
setServers([]);
setRawContent(null);
setDraftContent(EMPTY_MCP_JSON);
setStatusMessage(null);
setStatusTone(null);
setLoading(false);
return;
}
@@ -139,6 +147,7 @@ export default function McpPage() {
try {
setLoading(true);
setStatusMessage(null);
setStatusTone(null);
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/mcp`);
const payload = await res.json();
@@ -148,22 +157,75 @@ export default function McpPage() {
? payload.error
: "Failed to load MCP servers";
setStatusMessage(message);
setStatusTone("error");
setServers([]);
setRawContent(null);
setDraftContent(EMPTY_MCP_JSON);
return;
}
setRawContent(typeof payload?.content === "string" ? payload.content : null);
const content =
typeof payload?.content === "string" ? payload.content : null;
setRawContent(content);
setDraftContent(content ?? EMPTY_MCP_JSON);
setServers(normalizeServers(payload?.servers));
} catch {
setStatusMessage("Failed to load MCP servers");
setStatusTone("error");
setServers([]);
setRawContent(null);
setDraftContent(EMPTY_MCP_JSON);
} finally {
setLoading(false);
}
}
async function handleSaveRawContent() {
if (!selectedProjectId) return;
try {
setSaving(true);
setStatusMessage(null);
setStatusTone(null);
const res = await fetch(
`/api/projects/${encodeURIComponent(selectedProjectId)}/mcp`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: draftContent }),
}
);
const payload = await res.json();
if (!res.ok) {
throw new Error(
typeof payload?.error === "string"
? payload.error
: "Failed to save MCP servers"
);
}
const content =
typeof payload?.content === "string" ? payload.content : draftContent;
setRawContent(content);
setDraftContent(content);
setServers(normalizeServers(payload?.servers));
setStatusMessage("MCP configuration saved.");
setStatusTone("success");
} catch (error) {
setStatusMessage(
error instanceof Error ? error.message : "Failed to save MCP servers"
);
setStatusTone("error");
} finally {
setSaving(false);
}
}
const baselineContent = rawContent ?? EMPTY_MCP_JSON;
const hasDraftChanges = draftContent !== baselineContent;
const canSaveDraft = rawContent === null || hasDraftChanges;
const filteredServers = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return servers;
@@ -188,7 +250,7 @@ export default function McpPage() {
<div className="space-y-1">
<h2 className="text-2xl font-semibold">MCP Servers</h2>
<p className="text-sm text-muted-foreground">
View MCP servers configured for each project from
View and edit MCP servers configured for each project from
<span className="font-mono"> .meta/mcp/servers.json </span>
and switch between projects.
</p>
@@ -224,7 +286,15 @@ export default function McpPage() {
</div>
{statusMessage && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
<div
className={`rounded-md border px-3 py-2 text-sm ${
statusTone === "error"
? "border-destructive/40 bg-destructive/10 text-destructive"
: statusTone === "success"
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted/40"
}`}
>
{statusMessage}
</div>
)}
@@ -322,15 +392,53 @@ export default function McpPage() {
)}
</div>
{rawContent ? (
{selectedProjectId ? (
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3">
<div className="flex items-center justify-between border-b px-4 py-3">
<h3 className="text-sm font-medium">Raw servers.json</h3>
{!loading && (
<span className="text-xs text-muted-foreground">
Edit JSON directly
</span>
)}
</div>
<div className="p-4">
<pre className="max-h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap break-words">
{rawContent}
</pre>
<div className="space-y-3 p-4">
{!loading && !rawContent && (
<p className="text-xs text-muted-foreground">
`servers.json` does not exist yet for this project. Save to create it.
</p>
)}
<textarea
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
placeholder='{"mcpServers": {}}'
rows={10}
disabled={loading || saving}
className="w-full rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
/>
<div className="flex items-center gap-2">
<Button
onClick={handleSaveRawContent}
disabled={loading || saving || !canSaveDraft}
className="gap-2"
>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
"Save servers.json"
)}
</Button>
<Button
variant="outline"
onClick={() => setDraftContent(baselineContent)}
disabled={loading || saving || !hasDraftChanges}
>
Reset
</Button>
</div>
</div>
</div>
) : null}

View File

@@ -1,4 +1,3 @@
"use client";
import { useEffect, useState } from "react";
@@ -18,6 +17,10 @@ export default function ProjectDetailsPage() {
const router = useRouter();
const id = params.id as string;
const [project, setProject] = useState<Project | null>(null);
const [instructionsDraft, setInstructionsDraft] = useState("");
const [instructionsSaving, setInstructionsSaving] = useState(false);
const [instructionsStatus, setInstructionsStatus] = useState<string | null>(null);
const [instructionsStatusTone, setInstructionsStatusTone] = useState<"success" | "error" | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -28,6 +31,7 @@ export default function ProjectDetailsPage() {
})
.then((data: Project) => {
setProject(data);
setInstructionsDraft(data.instructions || "");
setLoading(false);
})
.catch(() => {
@@ -36,6 +40,41 @@ export default function ProjectDetailsPage() {
});
}, [id]);
async function handleSaveInstructions() {
if (!project) return;
try {
setInstructionsSaving(true);
setInstructionsStatus(null);
setInstructionsStatusTone(null);
const res = await fetch(`/api/projects/${project.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instructions: instructionsDraft }),
});
const payload = (await res.json()) as Project | { error?: string };
if (!res.ok) {
throw new Error(
"error" in payload && typeof payload.error === "string"
? payload.error
: "Failed to save instructions"
);
}
setProject(payload as Project);
setInstructionsDraft((payload as Project).instructions || "");
setInstructionsStatus("Instructions updated.");
setInstructionsStatusTone("success");
} catch (error) {
setInstructionsStatus(
error instanceof Error ? error.message : "Failed to save instructions"
);
setInstructionsStatusTone("error");
} finally {
setInstructionsSaving(false);
}
}
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
@@ -55,6 +94,8 @@ export default function ProjectDetailsPage() {
);
}
const instructionsDirty = instructionsDraft !== (project.instructions || "");
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
@@ -93,8 +134,48 @@ export default function ProjectDetailsPage() {
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Instructions
</h3>
<div className="bg-muted/50 p-4 rounded-lg text-sm font-mono whitespace-pre-wrap">
{project.instructions || "No custom instructions defined."}
{instructionsStatus && (
<div
className={`rounded-md border px-3 py-2 text-sm ${
instructionsStatusTone === "error"
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
}`}
>
{instructionsStatus}
</div>
)}
<textarea
value={instructionsDraft}
onChange={(e) => setInstructionsDraft(e.target.value)}
placeholder="No custom instructions defined."
disabled={instructionsSaving}
className="min-h-[140px] w-full rounded-lg border bg-muted/50 p-4 text-sm font-mono whitespace-pre-wrap focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
/>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSaveInstructions}
disabled={instructionsSaving || !instructionsDirty}
className="gap-2"
>
{instructionsSaving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setInstructionsDraft(project.instructions || "")}
disabled={instructionsSaving || !instructionsDirty}
>
Reset
</Button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { BookText, Loader2, Puzzle, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
@@ -22,9 +23,17 @@ interface ProjectContextSectionProps {
projectId: string;
}
const EMPTY_MCP_JSON = JSON.stringify({ mcpServers: {} }, null, 2);
export function ProjectContextSection({ projectId }: ProjectContextSectionProps) {
const [mcpContent, setMcpContent] = useState<string | null>(null);
const [mcpDraft, setMcpDraft] = useState(EMPTY_MCP_JSON);
const [mcpLoading, setMcpLoading] = useState(true);
const [mcpSaving, setMcpSaving] = useState(false);
const [mcpStatus, setMcpStatus] = useState<string | null>(null);
const [mcpStatusTone, setMcpStatusTone] = useState<"success" | "error" | null>(
null
);
const [skills, setSkills] = useState<ProjectSkillItem[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
@@ -36,6 +45,8 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
async function loadContext() {
setMcpLoading(true);
setSkillsLoading(true);
setMcpStatus(null);
setMcpStatusTone(null);
try {
const [mcpRes, skillsRes] = await Promise.all([
@@ -45,9 +56,13 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
if (mcpRes.ok) {
const mcpData = await mcpRes.json();
setMcpContent(typeof mcpData.content === "string" ? mcpData.content : null);
const content =
typeof mcpData.content === "string" ? mcpData.content : null;
setMcpContent(content);
setMcpDraft(content ?? EMPTY_MCP_JSON);
} else {
setMcpContent(null);
setMcpDraft(EMPTY_MCP_JSON);
}
if (skillsRes.ok) {
@@ -73,6 +88,7 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
}
} catch {
setMcpContent(null);
setMcpDraft(EMPTY_MCP_JSON);
setSkills([]);
} finally {
setMcpLoading(false);
@@ -88,6 +104,46 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
setSkillSheetOpen(true);
}
async function handleSaveMcp() {
try {
setMcpSaving(true);
setMcpStatus(null);
setMcpStatusTone(null);
const res = await fetch(`/api/projects/${projectId}/mcp`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: mcpDraft }),
});
const payload = await res.json();
if (!res.ok) {
throw new Error(
typeof payload?.error === "string"
? payload.error
: "Failed to save MCP config"
);
}
const content =
typeof payload?.content === "string" ? payload.content : mcpDraft;
setMcpContent(content);
setMcpDraft(content);
setMcpStatus("MCP configuration saved.");
setMcpStatusTone("success");
} catch (error) {
setMcpStatus(
error instanceof Error ? error.message : "Failed to save MCP config"
);
setMcpStatusTone("error");
} finally {
setMcpSaving(false);
}
}
const mcpBaseline = mcpContent ?? EMPTY_MCP_JSON;
const mcpDirty = mcpDraft !== mcpBaseline;
const mcpCanSave = mcpContent === null || mcpDirty;
return (
<>
<div className="grid gap-4 lg:grid-cols-2">
@@ -104,15 +160,57 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
<Loader2 className="size-4 animate-spin" />
Loading MCP config...
</div>
) : !mcpContent ? (
<div className="p-4 text-sm text-muted-foreground">
No `servers.json` found for this project.
</div>
) : (
<div className="p-4">
<pre className="max-h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap break-words">
{mcpContent}
</pre>
<div className="space-y-3 p-4">
{!mcpContent && (
<p className="text-xs text-muted-foreground">
No `servers.json` found for this project. Save to create it.
</p>
)}
{mcpStatus && (
<div
className={`rounded-md border px-3 py-2 text-xs ${
mcpStatusTone === "error"
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
}`}
>
{mcpStatus}
</div>
)}
<textarea
value={mcpDraft}
onChange={(e) => setMcpDraft(e.target.value)}
placeholder='{"mcpServers": {}}'
rows={10}
disabled={mcpSaving}
className="w-full rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
/>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSaveMcp}
disabled={mcpSaving || !mcpCanSave}
className="gap-2"
>
{mcpSaving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setMcpDraft(mcpBaseline)}
disabled={mcpSaving || !mcpDirty}
>
Reset
</Button>
</div>
</div>
)}
</div>

View File

@@ -261,6 +261,80 @@ export async function deleteProjectMcpServer(
return { success: true, filePath };
}
export async function saveProjectMcpServersContent(
projectId: string,
rawContent: string
): Promise<
| {
success: true;
filePath: string;
content: string;
servers: McpServerConfig[];
}
| { success: false; error: string }
> {
const trimmed = rawContent.trim();
const defaultContent = JSON.stringify({ mcpServers: {} }, null, 2);
const parseTarget = trimmed ? rawContent : defaultContent;
let parsed: unknown;
try {
parsed = JSON.parse(parseTarget);
} catch {
return {
success: false,
error: "Invalid JSON. Provide a valid servers.json object.",
};
}
const maybeCursor = parsed as McpServersFileCursor;
const isCursorObject =
maybeCursor?.mcpServers &&
typeof maybeCursor.mcpServers === "object" &&
!Array.isArray(maybeCursor.mcpServers);
const normalized = normalizeMcpServersFile(parsed);
if (!normalized && !isCursorObject) {
return {
success: false,
error:
"Unsupported format. Use { \"mcpServers\": { ... } } (Cursor format) or { \"servers\": [ ... ] }.",
};
}
const servers = normalized?.servers ?? [];
for (const server of servers) {
const idError = validateMcpServerId(server.id);
if (idError) {
return { success: false, error: `Invalid server id "${server.id}": ${idError}` };
}
if (server.transport === "http" && !server.url.trim()) {
return { success: false, error: `HTTP server "${server.id}" requires a non-empty url.` };
}
if (server.transport === "stdio" && !server.command.trim()) {
return {
success: false,
error: `STDIO server "${server.id}" requires a non-empty command.`,
};
}
}
const cursor = normalized ? toCursorMcpServersFile(normalized) : { mcpServers: {} };
const content = JSON.stringify(cursor, null, 2);
await ensureDir(getProjectMcpDir(projectId));
const filePath = getProjectMcpServersPath(projectId);
await fs.writeFile(filePath, content, "utf-8");
return {
success: true,
filePath,
content,
servers,
};
}
const SKILL_FILE = "SKILL.md";
/** Agent Skills spec: lowercase, numbers, hyphens; no leading/trailing/consecutive hyphens (e.g. pdf, pdf-parsing) */