Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/reliable-mcp-operation-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Preserve complete structured MCP action results for clients without inline apps, so mutation receipts remain available when display text is shortened.
5 changes: 5 additions & 0 deletions .changeset/static-registry-audit-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Include core actions in the agent tool catalog when an app supplies a static action registry, so audit history remains discoverable while respecting disabled framework tool groups.
8 changes: 7 additions & 1 deletion packages/core/src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@ export interface ActionMcpAppResourceConfig {
}

export interface ActionMcpAppConfig {
/** Preserve the sanitized object result alongside concise text, even without an inline app. Use for durable mutation receipts. */
structuredContent?: boolean;
/**
* Optional MCP Apps UI resource for hosts that render inline app iframes.
* Required when the action should open an interactive app view. Omit when
Expand Down Expand Up @@ -1125,7 +1127,11 @@ export function defineAction(options: any) {
return undefined;
}
// compactCatalog-only: no resource required; just keep the flag.
if (options.mcpApp.compactCatalog === true && !options.mcpApp.resource) {
if (
(options.mcpApp.compactCatalog === true ||
options.mcpApp.structuredContent === true) &&
!options.mcpApp.resource
) {
return options.mcpApp as ActionMcpAppConfig;
}
// Full resource: validate html is present.
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/agent/production-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,39 @@ describe("buildUserContentWithAttachments", () => {
expect(writeTool.description).toContain("Plan mode blocked");
});

it("keeps object-only union actions available to the in-app agent", () => {
const anyOf = [
{
type: "object",
properties: { operation: { const: "create" } },
required: ["operation"],
},
{
type: "object",
properties: { operation: { const: "update" } },
required: ["operation"],
},
];
const tools = actionsToEngineTools({
setup: {
tool: {
description: "Configure a database",
parameters: { anyOf } as any,
},
run: async () => ({}),
},
scalar: {
tool: {
description: "Invalid tool",
parameters: { type: "string" } as any,
},
run: async () => ({}),
},
});
expect(tools.map((tool) => tool.name)).toEqual(["setup"]);
expect(tools[0].inputSchema).toMatchObject({ type: "object", anyOf });
});

it("keeps the default initial catalog to discovery/runtime tools", () => {
const tools = actionsToEngineTools(
attachToolSearch({
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/agent/production-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { preUploadAttachments } from "../file-upload/pre-upload-attachments.js";
import { isMcpActionResult } from "../mcp-client/app-result.js";
import { extractMcpToolResultImages } from "../mcp-client/index.js";
import { isMcpToolAllowedForRequest } from "../mcp-client/visibility.js";
import { isObjectOnly } from "../mcp/tool-input-schema.js";
import { shouldInferSentimentForTurn } from "../observability/sentiment.js";
import {
completeRun as completeProgressRun,
Expand Down Expand Up @@ -3744,7 +3745,7 @@ function normalizeToolInputSchema(
schema: ActionTool["parameters"] | undefined,
): EngineTool["inputSchema"] | null {
if (!schema) return { type: "object", properties: {} };
if (schema.type !== "object") return null;
if (!isObjectOnly(schema)) return null;
type ToolParams = NonNullable<ActionTool["parameters"]>;
let cloned: ToolParams;
try {
Expand Down
12 changes: 5 additions & 7 deletions packages/core/src/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2388,8 +2388,9 @@ export async function createMCPServerForRequest(
Array.isArray(toolVisibility) &&
toolVisibility.length > 0 &&
toolVisibility.every((v) => v === "app");
const readOnlyStructuredResult =
entry.readOnly === true &&
const structuredResult =
(entry.readOnly === true ||
entry.mcpApp?.structuredContent === true) &&
rawResultForClient &&
typeof rawResultForClient === "object"
? Array.isArray(rawResultForClient)
Expand All @@ -2403,11 +2404,8 @@ export async function createMCPServerForRequest(
typeof rawResult === "object" &&
!Array.isArray(rawResult)
? (rawResult as Record<string, unknown>)
: readOnlyStructuredResult
? mcpAppStructuredContent(
readOnlyStructuredResult,
responseMeta,
)
: structuredResult
? mcpAppStructuredContent(structuredResult, responseMeta)
: undefined;
const text = mcpAppResource
? conciseMcpAppToolText(name, resultForClient, structuredContent!)
Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/mcp/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3871,22 +3871,20 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
expect(out.result.content[0].text).not.toContain("embed-session-ticket");
});

it("does NOT surface raw result via structuredContent for model-visible (non-app-only) tools", async () => {
// Counter-regression: only `visibility: ["app"]` tools get the raw
// structuredContent escape hatch. Tools the LLM can call must continue
// to go through the normal text + purge path so embed-start URLs and
// other internal fields stay hidden from the model.
it("preserves complete mutation receipts while sanitizing model-visible structured results", async () => {
const embedConfig = {
...config,
actions: {
"model-callable-helper": {
mcpApp: { structuredContent: true },
tool: {
description: "A normal model-visible tool",
// No `visibility` hint = model + app visible.
},
run: async () => ({
startUrl: "/_agent-native/embed/start?ticket=should-be-hidden",
payload: "ok",
payload: "x".repeat(3000),
receipt: { id: "operation-42", verified: true },
}),
},
},
Expand All @@ -3906,7 +3904,13 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
);

expect(out.error).toBeUndefined();
expect(out.result.structuredContent).toBeUndefined();
expect(out.result.structuredContent).toEqual({
payload: "x".repeat(3000),
receipt: { id: "operation-42", verified: true },
});
expect(JSON.stringify(out.result.structuredContent)).not.toContain(
"should-be-hidden",
);
expect(out.result.content[0].text).not.toContain("should-be-hidden");
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/mcp/tool-input-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Tool } from "@modelcontextprotocol/server";

function isObjectOnly(
export function isObjectOnly(
schema: unknown,
ancestors = new Set<unknown>(),
): boolean {
Expand Down
28 changes: 27 additions & 1 deletion packages/core/src/server/action-discovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import path from "node:path";

import { afterEach, describe, expect, it } from "vitest";

import { resolveFrameworkTools } from "../framework-tools.js";
import {
filterFrameworkToolGroups,
resolveFrameworkTools,
} from "../framework-tools.js";
import {
ALWAYS_ON_CORE_ACTIONS,
autoDiscoverActions,
Expand Down Expand Up @@ -68,6 +71,29 @@ describe("action discovery", () => {
expect(registry["mutating-read"].readOnly).toBe(false);
});

it(
"makes audit reads available with a static registry while respecting disabled groups",
async () => {
const registry = loadActionsFromStaticRegistry({});
await mergeCoreSharingActions(registry);
const enabled = filterFrameworkToolGroups(
registry,
resolveFrameworkTools({}).disabledGroups,
);
const disabled = filterFrameworkToolGroups(
registry,
resolveFrameworkTools({ frameworkTools: { audit: false } })
.disabledGroups,
);
for (const name of ["list-audit-events", "get-audit-event"]) {
expect(enabled[name]?.readOnly).toBe(true);
expect(disabled[name]).toBeUndefined();
expect(registry[name]).toBeDefined();
}
},
CORE_ACTION_DISCOVERY_TIMEOUT_MS,
);

it("preserves grounding metadata from static action entries", () => {
const registry = loadActionsFromStaticRegistry({
"grounded-query": {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/server/agent-chat-plugin.surface.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,16 @@ describe("framework tool gating — wiring guards", () => {
encoding: "utf-8",
});

it("merges core actions before filtering an explicit agent registry", () => {
const merge = source.indexOf(
"await mergeCoreSharingActions(templateScriptsAll);",
);
expect(merge).toBeGreaterThan(source.indexOf("const rawActions ="));
expect(merge).toBeLessThan(
source.indexOf("filterAgentTools(templateScriptsAll)"),
);
});

it("resolves the framework tool surface once and gates both agent registries", () => {
expect(source).toContain(
"const frameworkTools = resolveFrameworkTools(options);",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/server/agent-chat-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,8 @@ export function createAgentChatPlugin(
} catch {
// Package action registration is optional.
}
const { mergeCoreSharingActions } = await import("./action-discovery.js");
await mergeCoreSharingActions(templateScriptsAll);

// Resource, chat, docs, db, and cross-agent scripts are available in both
// prod and dev modes, unless the app switched the group off through
Expand Down
2 changes: 2 additions & 0 deletions scripts/guard-db-tool-scoping.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const SKIP_DIRS = new Set([
// access is mediated through a scoped parent, custom action, public token, or
// cache pathway. Key format: "<template>:<sql_table_name>".
const INTENTIONAL_RAW_DB_DENYLIST = {
"content:content_database_setup_receipts":
"actor-scoped retry receipts; access is rechecked through database setup actions",
"analytics:bigquery_cache": "provider cache, not a user-facing resource",
"analytics:first_party_analytics_cache":
"internal query cache, accessed through scoped analytics queries",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,31 @@ still reads as the active context. In table views, clicking a row title opens
that side preview; inline title editing lives behind the hover pencil
affordance.

## Ordinary database setup through MCP

Resolve an exact authorized space before creating an ordinary database. Creation,
safe property edits, saved table-view edits, and recoverable database Trash/restore
use caller intent keys and verified receipts. Repeat an unchanged request with its
original key after a lost response; a different payload needs a different key.

Database discovery returns the mutation target, schema revision, configuration
revision, supported setup operations, and field write restrictions. Use these
fresh values for the next mutation. Stale revisions require a read and a new
decision, not an automatic overwrite. Property and view names are labels; their
stable IDs identify edits. Sparse patches preserve omitted fields, and empty
lists explicitly clear supported settings.

Ordinary setup supports stored property types, additive options, metadata edits,
the existing text natural key, and table presentation. It refuses destructive
type/option changes, source-managed schema edits, and relationship/computed-field
authoring. Source status is readable, but this surface does not define joins,
row unions, source bindings, or write-mode changes. Discovery of a source-backed
row never establishes permission to write its fields.

Read back each changed object separately, then use its Open in Content link.
Rows retain both membership and Page identity in their links. Trash remains
recoverable; never substitute permanent deletion for ordinary cleanup.

## Property types

Document properties are SQL-backed, Notion-style structured metadata rather
Expand Down
38 changes: 30 additions & 8 deletions templates/content/actions/_content-space-access.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ActionContractError } from "@agent-native/core/action";
import { getDbExec } from "@agent-native/core/db";
import { getRequestUserEmail } from "@agent-native/core/server/request-context";
import { and, eq, isNull, sql } from "drizzle-orm";
Expand All @@ -22,7 +23,11 @@ type ContentOrganizationMembership = {

export function normalizeContentSpaceEmail(email: string): string {
const normalized = email.trim().toLowerCase();
if (!normalized) throw new Error("no authenticated user");
if (!normalized)
throw new ActionContractError("Sign in to select a Content space.", {
errorCode: "UNAUTHORIZED",
statusCode: 401,
});
return normalized;
}

Expand Down Expand Up @@ -190,18 +195,28 @@ export async function resolveContentSpaceAccess(
options: { db?: any } = {},
): Promise<ContentSpaceAccess> {
const userEmail = getRequestUserEmail();
if (!userEmail) throw new Error("no authenticated user");
if (!userEmail)
throw new ActionContractError("Sign in to select a Content space.", {
errorCode: "UNAUTHORIZED",
statusCode: 401,
});
const normalizedUserEmail = normalizeContentSpaceEmail(userEmail);
const [space] = await (options.db ?? getDb())
.select()
.from(schema.contentSpaces)
.where(eq(schema.contentSpaces.id, spaceId));
if (!space || space.archivedAt)
throw new Error(`Content space "${spaceId}" not found`);
throw new ActionContractError("Content space not found.", {
errorCode: "SPACE_NOT_FOUND",
statusCode: 404,
});

if (!space.orgId) {
if (normalizeContentSpaceEmail(space.ownerEmail) !== normalizedUserEmail) {
throw new Error(`Not authorized for Content space "${spaceId}"`);
throw new ActionContractError("Content space not found.", {
errorCode: "SPACE_NOT_FOUND",
statusCode: 404,
});
}
return {
space,
Expand All @@ -216,7 +231,10 @@ export async function resolveContentSpaceAccess(
options,
);
if (!membership)
throw new Error(`Not authorized for Content space "${spaceId}"`);
throw new ActionContractError("Content space not found.", {
errorCode: "SPACE_NOT_FOUND",
statusCode: 404,
});
const role: ContentSpaceRole =
membership.role === "owner"
? "owner"
Expand All @@ -229,12 +247,16 @@ export async function resolveContentSpaceAccess(
membership.role !== "admin" &&
membership.role !== "member"
) {
throw new Error(
`Contributor access is required for Content space "${spaceId}"`,
throw new ActionContractError(
"Contributor access is required for this Content space.",
{ errorCode: "FORBIDDEN", statusCode: 403 },
);
}
if (requiredRole === "editor" && role === "viewer") {
throw new Error(`Editor access is required for Content space "${spaceId}"`);
throw new ActionContractError(
"Editor access is required for this Content space.",
{ errorCode: "FORBIDDEN", statusCode: 403 },
);
}
return {
space,
Expand Down
Loading
Loading