Skip to content

Commit 2e66489

Browse files
committed
Enable external MCP Apps with Plugin Programs
1 parent c95b067 commit 2e66489

12 files changed

Lines changed: 518 additions & 49 deletions

File tree

docs/features/remote-mcp-apps/README.md

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ There are two distribution paths:
1313
URIs, UI metadata, results, and same-server app calls keep their provider
1414
meaning.
1515
2. A self-contained HTML file imported by URL. This is a convenience adapter
16-
for a static app bundle. OpenWork caches the bytes, exposes one standard MCP
17-
launch tool and one immutable `ui://` resource, and does not invent a second
18-
tool or capability protocol.
16+
for an externally authored app bundle. OpenWork caches the bytes, exposes
17+
one standard MCP launch tool and one immutable `ui://` resource, and may
18+
expose an app-specific, app-only Program tool on that same MCP server. The
19+
Program, not the browser UI, composes OpenWork Connect capabilities.
1920

2021
Programs remain executable `script` config objects. A Program's generated
2122
views are MCP resources, but turning a view into a resource does not turn
@@ -87,9 +88,20 @@ ui://openwork/library-apps/{appId}/revisions/{revisionId}/index.html
8788
```
8889

8990
The launch result uses ordinary `structuredContent` for the app identity,
90-
revision, digest, and optional input. There are no OpenWork capability wrapper
91-
tools. If the app needs tools, publish it with a standard MCP server and add
92-
that server through Connect.
91+
revision, digest, optional input, and the name of any available same-server
92+
Program tool. When Code Mode is enabled, an app-specific `run_program_*` tool
93+
is advertised with `_meta.ui.visibility: ["app"]`. Its exact name is delivered
94+
in the launch result rather than embedded in the HTML. It accepts an optional
95+
exact `programId` or uses the member's selected Program, and rejects Programs
96+
outside the app's owning Plugin. Normal host approval and Plugin access checks
97+
still apply. The Program executes server-side and receives the member's
98+
authorized Connect tool tree; the HTML receives only the Program result.
99+
100+
This is the only OpenWork-specific execution affordance in the URL adapter.
101+
The transport remains standard MCP `tools/call`, and the tool stays on the
102+
same server as the `ui://` resource. OpenWork does not let a browser app call
103+
tools on another MCP server. Apps distributed with their own MCP server keep
104+
using that server's native tools and should be added through Connect.
93105

94106
## Authoring and execution contract
95107

@@ -110,6 +122,8 @@ The execution contract is intentionally portable:
110122
- local development may provide mock tool results directly to the UI;
111123
- production MCP execution supplies input and results through the MCP Apps
112124
bridge;
125+
- an imported app can call the advertised same-server Program tool, while the
126+
Program calls OpenWork Connect capabilities server-side;
113127
- credentials remain in the MCP host/Connect connection and never enter the
114128
HTML resource;
115129
- an app calls only tools from its originating MCP server;
@@ -132,18 +146,24 @@ For a static bundle:
132146
validation and shows the resolved source, size, and digest.
133147
3. Import and activate. The adapter appears as an App inside its owning Plugin
134148
and shares through the existing Plugin/Marketplace access model.
135-
4. Refresh caches a new immutable draft without changing the active revision.
136-
5. Activate or roll back explicitly. Retire removes the launch tool from agent
149+
4. To give the UI Connect-backed behavior, open **Manage Plugin**, add a Code
150+
Mode Program to that Plugin, and select it. The app-specific tool rejects a
151+
selected Program from any other Plugin.
152+
5. Refresh caches a new immutable draft without changing the active revision.
153+
6. Activate or roll back explicitly. Retire removes the launch tool from agent
137154
discovery without deleting cached revisions; restore re-exposes the active
138155
revision.
139-
6. Download always returns the exact cached HTML revision, so the installed
156+
7. Download always returns the exact cached HTML revision, so the installed
140157
copy remains usable after the source URL disappears.
141158

142-
The static adapter is independent of `codemodeScripts`. Runtime discovery is
143-
gated by `DEN_REMOTE_MCP_APPS_ENABLED` until a compatible Desktop host is
144-
released. Normal MCP Apps delivered by an existing Connect server use the
145-
standard Connect and Desktop MCP host paths rather than this static-adapter
146-
flag.
159+
The static adapter is independently disableable with
160+
`DEN_REMOTE_MCP_APPS_ENABLED`, but defaults on after the compatible Desktop
161+
host release. `DEN_GENERATED_ARTIFACT_VIEWS_ENABLED` remains off by default;
162+
imported UI availability does not let agents author or compile MCP App UI in
163+
OpenWork. The optional Program tool is present only for organizations with
164+
`codemodeScripts` enabled. Normal MCP Apps delivered by an existing Connect
165+
server use the standard Connect and Desktop MCP host paths rather than this
166+
static-adapter flag.
147167

148168
## Host security and compatibility
149169

ee/apps/den-api/.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ DEN_MCP_ADDITIONAL_RESOURCES=
3535
# Enable only after deploying a Desktop release that delivers render-tool
3636
# structuredContent through the stable MCP Apps bridge after initialization.
3737
DEN_GENERATED_ARTIFACT_VIEWS_ENABLED=false
38-
# Enable only after deploying a Desktop release with the stable MCP Apps host.
39-
# This is independent of Code Mode and generated Artifact view rollout.
40-
DEN_REMOTE_MCP_APPS_ENABLED=false
38+
# The stable Desktop MCP Apps host supports imported apps by default. Set false
39+
# for an operator rollback. This does not enable generated Artifact views.
40+
DEN_REMOTE_MCP_APPS_ENABLED=true
4141
# Leave unset on hosted/multi-tenant deployments. Set to 1 only when Den runs
4242
# inside a private network and your MCP servers are on private addresses.
4343
# OPENWORK_DEV_MODE=1 already exempts local dev.

ee/apps/den-api/src/env.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,10 +435,10 @@ const mcpConnectionsGatingEnabled =
435435
const generatedArtifactViewsEnabled =
436436
(parsed.DEN_GENERATED_ARTIFACT_VIEWS_ENABLED ?? "false").trim().toLowerCase() === "true"
437437

438-
// Imported apps use the same stable Desktop MCP Apps bridge but have an
439-
// independent lifecycle from generated Artifact views and Code Mode Programs.
438+
// Imported apps use the released stable Desktop MCP Apps bridge and remain
439+
// independently disableable without enabling agent-authored generated views.
440440
const remoteMcpAppsEnabled =
441-
(parsed.DEN_REMOTE_MCP_APPS_ENABLED ?? "false").trim().toLowerCase() === "true"
441+
(parsed.DEN_REMOTE_MCP_APPS_ENABLED ?? "true").trim().toLowerCase() === "true"
442442

443443
const devMode = (parsed.OPENWORK_DEV_MODE ?? "0").trim() === "1"
444444
const botIdProtectionEnabled = (parsed.DEN_BOTID_PROTECTION_ENABLED ?? "0").trim() === "1"

ee/apps/den-api/src/mcp/agent.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ const programRunOutputSchema = z.object({
171171

172172
export const AGENT_MCP_INSTRUCTIONS = [
173173
"This OpenWork Cloud MCP server uses standard MCP tools, resources, structured results, and list-changed notifications. OpenWork Programs and Remote MCP Apps add only durable identity, Plugin containment, access, retained resources and results, selection, and lifecycle around those MCP primitives.",
174-
"When Remote MCP App delivery is enabled, active apps in the member's Library appear as individually named launch tools backed by immutable ui:// resources.",
174+
"MCP App UI is authored and bundled outside OpenWork. Agents do not author, compile, or save MCP App UI in OpenWork. Active imported apps in the member's Library appear as individually named launch tools backed by immutable ui:// resources.",
175+
"An imported app may call the app-visible Program tool named in its launch structuredContent through the standard same-server MCP Apps bridge. That tool runs an exact accessible Program inside the app's Plugin, or the member's selected Program when no id is supplied; the Program is the only layer that reaches OpenWork Connect capabilities.",
175176
"A Program is an immutable-versioned Code Mode Script config object inside an OpenWork Connect Plugin. Organizations with Code Mode scripts enabled receive execute_capability_script, the backwards-compatible render_dynamic_artifact MCP App tool, and a constant-size Program catalog: search_programs, select_program, and clear_program_selection.",
176177
"To use a Program, search by Library metadata, select one exact accessible Program, then refresh the tool catalog. The selected context exposes run_selected_program and render_selected_program; rendering returns an Artifact as fallback text plus retained data in structuredContent and binds the exact immutable MCP App resource URI in the tool definition.",
177178
"When a member asks to keep a successful Code Mode result, save it as a Program inside the existing OpenWork Connect Plugin they name by passing that pluginId to the Code Mode save operation. Omit pluginId only for a private Program in the member's My Programs Plugin. A Program inherits discovery and sharing from its Plugin and any Marketplace containing that Plugin; do not create a separate Program package or marketplace entry.",
@@ -187,7 +188,6 @@ export const AGENT_MCP_INSTRUCTIONS = [
187188
"External MCP matches include the provider-advertised argumentsSchema, schemaDigest, and invocation.argumentsField. Put an object matching argumentsSchema in execute_capability.body and copy schemaDigest into execute_capability.schemaDigest.",
188189
"OpenWork always attempts the downstream provider call when local schema checks find a mismatch. schemaGuidance is advisory and appears alongside the provider result: if the provider succeeded, accept that result and do not retry solely because of the warning; if it failed, use the warning to correct the arguments or search again.",
189190
"If the provider returns invalid_capability_arguments, correct the listed issues and retry once with changed arguments; never retry the same arguments unchanged. If it returns unknown_capability, call search_capabilities again before retrying.",
190-
"When save_artifact_view is advertised, create or update the saved Script with an explicit JSON Schema outputSchema matching its returned data before calling it. React is injected into view source, so use React APIs without imports and render only the supplied data prop. A failed view build includes diagnostics: change the source once and retry with the returned artifactViewId; do not search for a different Artifact tool or call render_dynamic_artifact. After a successful save, call the exact render_artifact_* or preview_artifact_* tool named in its result.",
191191
"When a match has kind connection_status, name connectionStatus.connectionName and relay connectionStatus.action exactly. Distinguish the member's Your Connections page, the organization Connections dashboard, and the provider's own admin console.",
192192
"Connection probes are live. After the requested human fixes that connector, search again in the same task; otherwise do not retry unchanged or improvise workarounds through other tools.",
193193
].join("\n")
@@ -449,6 +449,85 @@ export function registerAgentMcpRoutes<T extends { Variables: RequestIdVariables
449449
}
450450
}
451451
const artifactContext = codemodeEnabled ? libraryContext : null
452+
const executeRemoteAppProgram = artifactContext
453+
? async ({ programId, pluginId, input }: { programId?: string; pluginId: string; input?: unknown }) => {
454+
try {
455+
const resolvedProgramId = programId
456+
?? (await getProgramAgentSelection(artifactContext))?.programId
457+
if (!resolvedProgramId) {
458+
return {
459+
isError: true,
460+
content: textContent(JSON.stringify({
461+
error: "program_not_selected",
462+
message: "Select a Program in OpenWork before this MCP App runs it, or pass an exact accessible programId.",
463+
})),
464+
}
465+
}
466+
const detail = await getProgramDetail({
467+
context: artifactContext,
468+
configObjectId: resolvedProgramId,
469+
})
470+
if (detail.script.pluginId !== pluginId) {
471+
return {
472+
isError: true,
473+
content: textContent(JSON.stringify({
474+
error: "program_not_in_app_plugin",
475+
message: "The selected Program is not inside this MCP App's Plugin.",
476+
})),
477+
}
478+
}
479+
await requirePluginArchResourceRole({
480+
context: artifactContext,
481+
requireFreshSession: false,
482+
resourceId: normalizeDenTypeId("configObject", resolvedProgramId),
483+
resourceKind: "config_object",
484+
role: "editor",
485+
})
486+
const execution = await executeMarketplaceCapability({
487+
organizationId: principal.organizationId,
488+
member: memberIdentity,
489+
pluginId: detail.script.pluginId,
490+
configObjectId: detail.script.configObjectId,
491+
configObjectVersionId: detail.script.currentVersion.id,
492+
body: input,
493+
codemodeEnabled: true,
494+
validateScriptOutput: true,
495+
buildTools: () => buildCapabilityToolTree(capabilityContext),
496+
})
497+
if (!execution.ok || execution.result.status !== "executed") {
498+
const message = execution.ok
499+
? execution.result.hint ?? "The Program could not run."
500+
: execution.message
501+
return {
502+
isError: true,
503+
content: textContent(JSON.stringify({ error: "program_run_failed", message })),
504+
}
505+
}
506+
const result = {
507+
status: "succeeded" as const,
508+
value: execution.result.value,
509+
receiptId: execution.result.receiptId ?? null,
510+
resultDigest: execution.result.resultDigest ?? null,
511+
}
512+
return {
513+
content: textContent(JSON.stringify(result, null, 2)),
514+
structuredContent: result,
515+
}
516+
} catch (error) {
517+
const unavailable = error instanceof PluginArchAuthorizationError
518+
|| (error instanceof Error && error.message.includes("not_found"))
519+
return {
520+
isError: true,
521+
content: textContent(JSON.stringify({
522+
error: unavailable ? "program_not_found" : "program_run_failed",
523+
message: unavailable
524+
? "The Program is not available to this member."
525+
: "The Program could not run.",
526+
})),
527+
}
528+
}
529+
}
530+
: undefined
452531
if (method === "initialize" || method === "resources/list" || method === "resources/read") {
453532
remoteSkills = [
454533
...listBuiltinSkillDescriptors(),
@@ -473,6 +552,7 @@ export function registerAgentMcpRoutes<T extends { Variables: RequestIdVariables
473552
})
474553
return { html: loaded.html, payload: loaded.payload }
475554
},
555+
runProgram: executeRemoteAppProgram,
476556
})
477557
}
478558
if (method === "initialize" || method === "resources/list" || method === "resources/read") {

ee/apps/den-api/src/mcp/remote-mcp-apps.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export function remoteMcpAppLaunchToolName(configObjectId: string) {
2323
return `launch_remote_app_${stableSuffix(configObjectId)}`
2424
}
2525

26+
export function remoteMcpAppRunProgramToolName(configObjectId: string) {
27+
return `run_program_${stableSuffix(configObjectId)}`
28+
}
29+
2630
function resourceMeta(revision: Pick<ActiveRemoteMcpApp, "payload">): { ui: McpUiResourceMeta; resourceDigest: string } {
2731
return {
2832
ui: { csp: revision.payload.resource.csp, prefersBorder: true },
@@ -37,9 +41,47 @@ export function registerAgentRemoteMcpApps(input: {
3741
html: string
3842
payload: ActiveRemoteMcpApp["payload"]
3943
}>
44+
runProgram?: (request: {
45+
appConfigObjectId: string
46+
pluginId: string
47+
programId?: string
48+
input?: unknown
49+
}) => Promise<{
50+
content: Array<{ type: "text"; text: string }>
51+
structuredContent?: Record<string, unknown>
52+
isError?: boolean
53+
}>
4054
}) {
55+
const runProgram = input.runProgram
4156
for (const app of input.apps) {
4257
const metadata = app.payload.metadata
58+
const runProgramToolName = remoteMcpAppRunProgramToolName(app.app.configObjectId)
59+
if (runProgram) {
60+
registerAppTool(
61+
input.server,
62+
runProgramToolName,
63+
{
64+
title: `Run ${metadata.name} Program`,
65+
description: [
66+
`Run a Code Mode Program inside the ${metadata.name} Plugin through OpenWork Connect.`,
67+
"Omit programId to use the member's selected Program, or pass an exact accessible Program id from this Plugin.",
68+
"This app-only tool stays on the same MCP server as the imported UI resource; the Program owns all downstream Connect capability calls.",
69+
].join(" "),
70+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
71+
inputSchema: z.object({
72+
programId: z.string().trim().min(1).max(160).optional(),
73+
input: z.unknown().optional(),
74+
}),
75+
_meta: { ui: { visibility: ["app"] } },
76+
},
77+
async ({ programId, input: programInput }) => runProgram({
78+
appConfigObjectId: app.app.configObjectId,
79+
pluginId: app.app.pluginId,
80+
...(programId ? { programId } : {}),
81+
...(programInput === undefined ? {} : { input: programInput }),
82+
}),
83+
)
84+
}
4385
for (const revision of app.revisions) {
4486
const metadata = resourceMeta(revision)
4587
registerAppResource(
@@ -91,6 +133,9 @@ export function registerAgentRemoteMcpApps(input: {
91133
revisionId: app.versionId,
92134
resourceDigest: app.payload.resource.digest,
93135
},
136+
...(input.runProgram ? {
137+
serverTools: { runProgram: runProgramToolName },
138+
} : {}),
94139
...(launchInput === undefined ? {} : { input: launchInput }),
95140
}
96141
return {

ee/apps/den-api/test/agent-codemode-script.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,12 @@ afterAll(() => {
148148
test("does not register execute_capability_script when the org flag is off", async () => {
149149
const tools = listedToolNames(await rpc(buildApp(), "tools/list"))
150150
expect(tools).not.toContain("execute_capability_script")
151+
expect(tools).not.toContain("save_artifact_view")
152+
expect(tools).not.toContain("activate_artifact_view_revision")
153+
expect(tools).not.toContain("retire_artifact_view")
151154
})
152155

153-
test("registers execute_capability_script when the org flag is on", async () => {
156+
test("registers Code Mode without enabling agent-authored MCP App views", async () => {
154157
organizationMetadata = { capabilities: { codemodeScripts: true } }
155158
const tools = listedTools(await rpc(buildApp(), "tools/list"))
156159
const names = tools.flatMap((tool) => typeof tool.name === "string" ? [tool.name] : [])
@@ -159,6 +162,9 @@ test("registers execute_capability_script when the org flag is on", async () =>
159162
expect(names).toContain("search_programs")
160163
expect(names).toContain("select_program")
161164
expect(names).toContain("clear_program_selection")
165+
expect(names).not.toContain("save_artifact_view")
166+
expect(names).not.toContain("activate_artifact_view_revision")
167+
expect(names).not.toContain("retire_artifact_view")
162168
expect(isRecord(tools.find((tool) => tool.name === "search_programs")?.outputSchema)).toBe(true)
163169
expect(isRecord(tools.find((tool) => tool.name === "select_program")?.outputSchema)).toBe(true)
164170
expect(isRecord(tools.find((tool) => tool.name === "clear_program_selection")?.outputSchema)).toBe(true)

0 commit comments

Comments
 (0)