Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/mesh/src/mcp-clients/virtual-mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getMcpListCache } from "../mcp-list-cache";
import type { StudioContext } from "../../core/studio-context";
import type { ConnectionEntity } from "../../tools/connection/schema";
import type { VirtualMCPEntity } from "../../tools/virtual/schema";
import { loadInstructionsFileText } from "./instructions-file";
import { PassthroughClient } from "./passthrough-client";
import { renderSkillsCatalogBlock } from "./skills-instructions";
import type { VirtualClientOptions } from "./types";
Expand Down Expand Up @@ -139,6 +140,12 @@ export async function createVirtualClientFrom(
? ((await renderSkillsCatalogBlock(ctx, virtualMcp)) ?? undefined)
: undefined;

// Prompt-linked-to-file: read the linked org-fs file now (async, same seam
// as the skills block) so getInstructions() can prefer it over the inline
// mirror on every run path. No-op (no read) for agents without a link.
const instructionsOverride =
(await loadInstructionsFileText(ctx, virtualMcp)) ?? undefined;

// Build aggregator options
const clientOptions: VirtualClientOptions = {
connections: loadedConnections,
Expand All @@ -147,6 +154,7 @@ export async function createVirtualClientFrom(
mcpListCache: getMcpListCache() ?? undefined,
listTimeoutMs: options?.listTimeoutMs,
skillsBlock,
instructionsOverride,
};

return new PassthroughClient(clientOptions, ctx);
Expand Down
39 changes: 39 additions & 0 deletions apps/mesh/src/mcp-clients/virtual-mcp/instructions-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Resolves the agent's system prompt from its linked org-fs file
* (`metadata.instructionsFile`). Read here (async, factory-side) — NOT inside
* the synchronous `getInstructions()` — and stashed on the client options,
* the same seam as the skills block, so it reaches both the cluster engine
* and the sandbox/desktop daemon. Best-effort: any failure degrades to the
* inline `metadata.instructions` mirror, never a failed client creation.
*/

import type { StudioContext } from "../../core/studio-context";
import {
buildPublicOrgFs,
isPublicVolume,
} from "../../file-storage/public-sets";
import type { VirtualMCPEntity } from "../../tools/virtual/schema";

export async function loadInstructionsFileText(
ctx: StudioContext,
virtualMcp: VirtualMCPEntity,
): Promise<string | null> {
const ref = virtualMcp.metadata?.instructionsFile;
if (!ref) return null;
try {
// `public-*` volumes live under the shared public scope, not the org's.
const orgFs = isPublicVolume(ref.volume)
? buildPublicOrgFs(ctx)
: ctx.orgFs;
if (!orgFs) return null;
return new TextDecoder().decode(await orgFs.read(ref.volume, ref.path));
} catch (err) {
console.warn("[virtual-mcp] failed to read linked instructions file", {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking — two minor observations:

  • This runs a fresh org-fs read (S3 GET) on every client creation for file-linked agents, uncached. That's the correct tradeoff since "external edit wins" needs freshness (and it mirrors skillsBlock), just flagging the per-run latency/cost.
  • The console.warn logs the full err object every run when a linked file is missing/unreadable — a deleted linked file will spam this once per run. Might drop to debug or trim the payload.

virtualMcpId: virtualMcp.id,
volume: ref.volume,
path: ref.path,
err,
});
return null;
}
}
6 changes: 5 additions & 1 deletion apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,11 @@ export class PassthroughClient extends GatewayClient {
// daemon both read instructions; only the cluster runs the richer
// buildAgentSystemPrompt).
const base = withKnowledge(
this.options.virtualMcp.metadata?.instructions ?? undefined,
// The linked instructions file (factory-read) wins over the inline
// mirror; the mirror is the fallback when the file is unreadable.
this.options.instructionsOverride ??
this.options.virtualMcp.metadata?.instructions ??
undefined,
this.options.virtualMcp.metadata?.knowledge,
);
// Append the pre-rendered <available-skills> catalog (built async in the
Expand Down
7 changes: 7 additions & 0 deletions apps/mesh/src/mcp-clients/virtual-mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,11 @@ export interface VirtualClientOptions {
* cluster engine and the sandbox/desktop daemon. Only set for agent runtimes.
*/
skillsBlock?: string;
/**
* System prompt read from the agent's linked org-fs file
* (`metadata.instructionsFile`). Same factory-side async pattern as
* `skillsBlock`; when set, `getInstructions()` prefers it over the inline
* `metadata.instructions` mirror.
*/
instructionsOverride?: string;
}
32 changes: 32 additions & 0 deletions apps/mesh/src/tools/virtual/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
requireOrganization,
} from "../../core/studio-context";
import { writeAgentPrompts } from "../../file-storage/agent-prompts";
import { isPublicVolume } from "../../file-storage/public-sets";
import { VirtualMCPEntitySchema, VirtualMCPUpdateDataSchema } from "./schema";
import { requireOrgAdminForPinnedField } from "./require-org-admin-for-pin";

Expand Down Expand Up @@ -79,6 +80,37 @@ export const COLLECTION_VIRTUAL_MCP_UPDATE = defineTool({
requireOrgAdminForPinnedField(ctx);
}

// Prompt-linked-to-file: an actual instructions change writes through to
// the linked org-fs file (the runtime source of truth). Gated on the
// incoming text differing from BOTH the stored mirror (so unrelated
// autosaves echoing an unchanged/stale mirror never clobber external file
// edits) and the current file content (so link-time saves don't churn the
// change feed). Runs before the row update so a failed write fails the
// whole update instead of silently diverging mirror and file. `public-*`
// volumes are readonly — the synced file is canonical there, never written.
const fileRef = data.metadata?.instructionsFile;
const incomingInstructions = input.data.metadata?.instructions;
if (
fileRef &&
typeof incomingInstructions === "string" &&
incomingInstructions !== existing.metadata?.instructions &&
!isPublicVolume(fileRef.volume) &&
ctx.orgFs
) {
const fileText = await ctx.orgFs
.read(fileRef.volume, fileRef.path)
.then((bytes) => new TextDecoder().decode(bytes))
.catch(() => null);
if (fileText !== incomingInstructions) {
await ctx.orgFs.write(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small notes on the write-through, neither blocking:

1. Permission scope (defense-in-depth). This writes via ctx.orgFs.write(...) after only ctx.access.check() (the agent-update permission), whereas the HTTP fs PUT route gates writes on ORG_FS_WRITE. Since fileRef is fully caller-supplied, a user who can edit an agent but lacks fs-write could technically overwrite any file in the org by pointing instructionsFile at it. Today this is harmless because ORG_FS_WRITE is basic-usage (every member has it — see the ACL note at the top of org-fs.ts), so there's no live exploit. But that file explicitly anticipates per-volume ACLs, and this becomes a real bypass the moment they land. writeAgentPrompts is the precedent, but it writes a fixed agent-scoped path — this one's arbitrary. Might be worth a TODO here, or routing through the same ORG_FS_WRITE check.

2. No notifyOrgFsChange. The HTTP PUT route fires it so other Library viewers / the recent-files feed refresh live. This write skips it, so a prompt edit landing on the file won't propagate to other clients (the editing client self-invalidates its own query, but that's it). Minor.

fileRef.volume,
fileRef.path,
incomingInstructions,
{ actor: userId },
);
}
}

const virtualMcp = await ctx.storage.virtualMcps.update(
input.id,
userId,
Expand Down
12 changes: 11 additions & 1 deletion apps/mesh/src/web/hooks/use-org-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export async function fetchOrgFsSkillCatalog(
}

/** Read a file's contents as UTF-8 text (org-fs `/read` endpoint). */
async function fetchOrgFsText(
export async function fetchOrgFsText(
orgSlug: string,
volume: string,
path: string,
Expand All @@ -249,6 +249,16 @@ async function fetchOrgFsText(
return res.text();
}

/** A text file's content, cached — powers the prompt-linked-file editor. */
export function useOrgFsReadText(volume: string | null, path: string) {
const { org } = useProjectContext();
return useQuery({
queryKey: KEYS.orgFsReadText(org.id, volume ?? "", path),
enabled: volume !== null,
queryFn: () => fetchOrgFsText(org.slug, volume ?? "", path),
});
}

/** A text file inside a skill folder, collected for inlining into chat. */
export interface OrgFsSkillFile {
/** Path relative to the skill dir (e.g. "SKILL.md", "references/style.md"). */
Expand Down
4 changes: 4 additions & 0 deletions apps/mesh/src/web/lib/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,10 @@ export const KEYS = {
["org-fs", orgId, volume, "usage"] as const,
orgFsStat: (orgId: string, volume: string, path: string) =>
["org-fs", orgId, volume, "stat", path] as const,
// A text file's content (prompt-linked-file editor). Nested under the
// volume prefix so write mutations refresh it with the listings.
orgFsReadText: (orgId: string, volume: string, path: string) =>
["org-fs", orgId, volume, "read-text", path] as const,
orgFsPublicSets: (orgId: string) => ["org-fs-public-sets", orgId] as const,
// Cross-volume recent-files feed (Library home). Separate root key so a
// volume named like the segment can never collide; mutations invalidate it
Expand Down
108 changes: 94 additions & 14 deletions apps/mesh/src/web/views/virtual-mcp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { IntegrationIcon } from "@/web/components/integration-icon.tsx";
import { usePanelActions } from "@/web/layouts/shell-layout";
import { User } from "@/web/components/user/user";
import { useMCPAuthStatus } from "@/web/hooks/use-mcp-auth-status";
import { fetchOrgFsText, useOrgFsReadText } from "@/web/hooks/use-org-fs.ts";
import { publicSetOf } from "@/web/layouts/library/location";

import {
authenticateMcp,
Expand Down Expand Up @@ -88,6 +90,7 @@ import { IconPicker } from "../../components/icon-picker";
import { SimpleIconPicker } from "../../components/simple-icon-picker";
import { Page } from "@/web/components/page";
import { AddConnectionDialog } from "./add-connection-dialog";
import { PromptFileLink, type PromptFileRef } from "./prompt-file-link";
import { FilesSection } from "./files-section";
import { SubAgentsSection } from "./sub-agents-section";
import { track } from "@/web/lib/posthog-client";
Expand Down Expand Up @@ -1153,6 +1156,29 @@ function VirtualMcpDetailViewWithData({
// GitHub repo connected (real auth) — instructions become read-only
const hasGithubRepo = agentHasConnectedGithub(virtualMcp);

// Prompt linked to an org-fs file: the file is the runtime source of truth
// (the update tool writes edits made here through to it); `public-*`
// volumes are readonly and lock the editor. Display prefers the fresh file
// content over the possibly-stale inline mirror; `promptDraft` holds
// in-progress edits until the post-save refetch echoes them back.
const promptFile = form.watch("metadata.instructionsFile") ?? null;
const promptFileReadOnly =
promptFile !== null && publicSetOf(promptFile.volume) !== null;
const { data: promptFileText } = useOrgFsReadText(
promptFile?.volume ?? null,
promptFile?.path ?? "",
);
const [promptDraft, setPromptDraft] = useState<string | null>(null);
// Post-save echo: the refetched file matches the draft — drop it so later
// external file updates show through. Render-phase adjust, not an effect.
if (promptDraft !== null && promptFileText === promptDraft) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, not blocking: the draft-clear leans on exact string equality (promptFileText === promptDraft). It's safe today because the write and read round-trip verbatim (no normalization on either side), but if any normalization ever creeps into the fs write path, the draft would never clear and later external edits wouldn't surface in the editor. A one-line comment naming that dependency would save a future debugging session.

setPromptDraft(null);
}
const promptDisplayValue = (fieldValue: string | null | undefined) =>
promptFile
? (promptDraft ?? promptFileText ?? fieldValue ?? "")
: (fieldValue ?? "");

// Repo info for the Runtime card (display-only — loose check is intentional)
const githubRepoForRuntimeCard = getActiveGithubRepo(virtualMcp);
const runtimeCardRepo = githubRepoForRuntimeCard
Expand All @@ -1178,8 +1204,10 @@ function VirtualMcpDetailViewWithData({

const handleImprovePrompt = async () => {
if (isImproving) return;
const currentInstructions = form.getValues("metadata.instructions");
if (!currentInstructions?.trim()) return;
const currentInstructions = promptDisplayValue(
form.getValues("metadata.instructions"),
);
if (!currentInstructions.trim()) return;

setIsImproving(true);
try {
Expand Down Expand Up @@ -1271,6 +1299,19 @@ function VirtualMcpDetailViewWithData({
data: payload,
});

// Refetch the linked prompt file after the server's write-through so the
// draft-clearing echo check (above) sees the new content.
const linkedFile = formData.metadata?.instructionsFile;
if (linkedFile) {
queryClient.invalidateQueries({
queryKey: KEYS.orgFsReadText(
org.id,
linkedFile.volume,
linkedFile.path,
),
});
}

// Accumulate into the current edit session and (re)schedule a flush
// 30s after the last save.
dispatchEditSession({
Expand Down Expand Up @@ -1548,6 +1589,34 @@ Define step-by-step how the agent should handle requests.
form.setValue("metadata.instructions", next, { shouldDirty: true });
};

const handleLinkPromptFile = async (file: PromptFileRef) => {
try {
const text = await fetchOrgFsText(org.slug, file.volume, file.path);
form.setValue("metadata.instructionsFile", file, { shouldDirty: true });
// Mirror the file content at link time so the entity fallback stays
// coherent (the server skips the no-op file write).
form.setValue("metadata.instructions", text, { shouldDirty: true });
setPromptDraft(null);
flushAndSave();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to read file.",
);
}
};

const handleUnlinkPromptFile = () => {
// Keep the currently-displayed text inline so unlinking never loses it.
form.setValue(
"metadata.instructions",
promptDisplayValue(form.getValues("metadata.instructions")),
{ shouldDirty: true },
);
form.setValue("metadata.instructionsFile", null, { shouldDirty: true });
setPromptDraft(null);
flushAndSave();
};

const addedConnectionIds = new Set(connections.map((c) => c.connection_id));
const navigate = useNavigate();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
Expand Down Expand Up @@ -1796,20 +1865,28 @@ Define step-by-step how the agent should handle requests.
Instructions
</h2>
<div className="flex items-center gap-2">
{!form.watch("metadata.instructions")?.trim() && (
<Button
variant="outline"
size="sm"
onClick={handleInsertTemplate}
>
+ Prompt template
</Button>
)}
<PromptFileLink
file={promptFile}
readOnly={promptFileReadOnly}
onLink={handleLinkPromptFile}
onUnlink={handleUnlinkPromptFile}
/>
{!promptFile &&
!form.watch("metadata.instructions")?.trim() && (
<Button
variant="outline"
size="sm"
onClick={handleInsertTemplate}
>
+ Prompt template
</Button>
)}
<Button
variant="outline"
size="sm"
disabled={
isImproving ||
promptFileReadOnly ||
!form.watch("metadata.instructions")?.trim()
}
onClick={handleImprovePrompt}
Expand All @@ -1826,14 +1903,16 @@ Define step-by-step how the agent should handle requests.
<div className="relative rounded-xl card-shadow bg-card focus-within:ring-1 focus-within:ring-ring">
<Textarea
{...field}
value={field.value ?? ""}
value={promptDisplayValue(field.value)}
onChange={(e) => {
if (promptFile) setPromptDraft(e.target.value);
field.onChange(e);
}}
onBlur={() => {
field.onBlur();
flushAndSave();
}}
disabled={promptFileReadOnly}
placeholder="Define how this agent should behave, what tone to use, any constraints or guidelines..."
className="min-h-[200px] max-h-[360px] overflow-auto resize-none text-base text-muted-foreground placeholder:text-muted-foreground/40 leading-relaxed border-0 shadow-none px-4 py-3 pr-11 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent"
style={{ boxShadow: "none" }}
Expand Down Expand Up @@ -1999,15 +2078,16 @@ Define step-by-step how the agent should handle requests.
render={({ field }) => (
<Textarea
{...field}
value={field.value ?? ""}
value={promptDisplayValue(field.value)}
onChange={(e) => {
if (promptFile) setPromptDraft(e.target.value);
field.onChange(e);
}}
onBlur={() => {
field.onBlur();
flushAndSave();
}}
disabled={hasGithubRepo}
disabled={hasGithubRepo || promptFileReadOnly}
placeholder="Define how this agent should behave, what tone to use, any constraints or guidelines..."
className="w-full h-full resize-none text-base text-muted-foreground placeholder:text-muted-foreground/40 leading-relaxed rounded-xl card-shadow px-4 py-3 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 bg-card border-0"
style={{ boxShadow: "none" }}
Expand Down
Loading
Loading