Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/mcp/src/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ describe("MCP write/proposal surface", () => {
"muster_get_kelpie_case",
"muster_search_knowledge",
"muster_get_knowledge",
"muster_list_invocations",
]);
expect(MCP_WRITE_TOOL_NAMES).toEqual([
"muster_propose_kelpie_action",
"muster_get_action_status",
"muster_propose_knowledge",
"muster_export_audit",
]);
expect(MCP_TOOL_NAMES).toEqual([
...MCP_READ_TOOL_NAMES,
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const MCP_READ_TOOL_NAMES = [
"muster_get_kelpie_case",
"muster_search_knowledge",
"muster_get_knowledge",
"muster_list_invocations",
] as const;

/**
Expand All @@ -15,6 +16,7 @@ export const MCP_WRITE_TOOL_NAMES = [
"muster_propose_kelpie_action",
"muster_get_action_status",
"muster_propose_knowledge",
"muster_export_audit",
] as const;

export const MCP_TOOL_NAMES = [
Expand All @@ -36,7 +38,9 @@ export const MCP_TOOL_VERSIONS: Record<McpToolName, string> = {
muster_propose_kelpie_action: "1.0.0",
muster_get_action_status: "1.0.0",
muster_propose_knowledge: "1.0.0",
muster_list_invocations: "1.0.0",
muster_export_audit: "1.0.0",
};

export const MCP_SERVER_NAME = "muster";
export const MCP_SERVER_VERSION = "0.3.0";
export const MCP_SERVER_VERSION = "0.4.0";
5 changes: 5 additions & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,9 @@ export {
KnowledgeProposalSchema,
type KnowledgeProposal,
} from "./knowledge.ts";
export {
exportAudit,
listInvocations,
AuditExportSchema,
} from "./observability.ts";
export { recordInvocation, type InvocationOutcome } from "./audit.ts";
2 changes: 2 additions & 0 deletions packages/mcp/src/mcp.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,13 @@ describeIntegration("Muster MCP vertical slice", () => {
// Server always registers the full tool surface; scope/capability gate
// execution. Default installation scopes remain the four read tools.
expect(tools.map((tool) => tool.name).sort()).toEqual([
"muster_export_audit",
"muster_get_action_status",
"muster_get_kelpie_case",
"muster_get_knowledge",
"muster_get_status",
"muster_list_capabilities",
"muster_list_invocations",
"muster_propose_kelpie_action",
"muster_propose_knowledge",
"muster_search_kelpie_cases",
Expand Down
216 changes: 216 additions & 0 deletions packages/mcp/src/observability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { and, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import { requireCapability } from "@muster/authz";
import { database, schema } from "@muster/database";
import { z } from "zod";
import { McpToolError } from "./errors.ts";
import { requireScope, type InstallationContext } from "./installation.ts";
import type { ToolResult } from "./tools.ts";

type Database = ReturnType<typeof database>;

const MAX_EXPORT = 100;

function redactMetadata(metadata: unknown): Record<string, unknown> {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
return {};
const src = metadata as Record<string, unknown>;
// Never return raw arguments, prompts, or reasoning — only stable
// attribution + hashes already stored by recordInvocation / actions.
const allowed = [
"tool",
"toolVersion",
"installationId",
"outcome",
"resultHash",
"evidenceRefs",
"errorCode",
"operation",
"capability",
"approvalId",
"via",
"kind",
"status",
"policyDecision",
"contentHash",
"notAuthorisationProof",
"policyReasons",
"idempotencyKey",
] as const;
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (key in src) out[key] = src[key];
}
return out;
}

/**
* Bounded list of recent MCP tool invocations for this organisation.
* Uses audit_events (mcp.tool.invoked). No private reasoning.
*/
export async function listInvocations(
db: Database,
context: InstallationContext,
args: { limit: number; tool?: string | undefined },
): Promise<ToolResult<unknown>> {
requireScope(context, "muster_list_invocations");
requireCapability(context.subject, "audit.read");

const limit = Math.min(Math.max(args.limit, 1), 50);
const conditions = [
eq(schema.auditEvents.organisationId, context.subject.organisationId),
eq(schema.auditEvents.action, "mcp.tool.invoked"),
];
if (args.tool) {
conditions.push(
sql`${schema.auditEvents.metadata}->>'tool' = ${args.tool}`,
);
}

const rows = await db
.select({
id: schema.auditEvents.id,
sequence: schema.auditEvents.sequence,
actorId: schema.auditEvents.actorId,
actorType: schema.auditEvents.actorType,
targetId: schema.auditEvents.targetId,
metadata: schema.auditEvents.metadata,
traceId: schema.auditEvents.traceId,
createdAt: schema.auditEvents.createdAt,
eventHash: schema.auditEvents.eventHash,
})
.from(schema.auditEvents)
.where(and(...conditions))
.orderBy(desc(schema.auditEvents.sequence))
.limit(limit);

return {
payload: {
records: rows.map((row) => ({
id: row.id,
sequence: row.sequence,
actorId: row.actorId,
actorType: row.actorType,
tool: row.targetId,
metadata: redactMetadata(row.metadata),
traceId: row.traceId,
createdAt: row.createdAt,
eventHash: row.eventHash,
})),
limit,
includesPrivateReasoning: false,
replayMode: "recorded_results_only",
},
evidenceRefs: rows.map((r) => r.id),
};
}

export const AuditExportSchema = z.object({
limit: z.number().int().min(1).max(MAX_EXPORT).default(25),
since: z.string().datetime().optional(),
until: z.string().datetime().optional(),
actions: z
.array(
z.enum([
"mcp.tool.invoked",
"integration.action.approval_requested",
"integration.action.queued",
"connector.query.queued",
"knowledge.proposed",
]),
)
.max(10)
.optional(),
});

/**
* Bounded audit export for evaluation. Replay consumers must use recorded
* hashes/results and must not re-issue external actions.
*/
export async function exportAudit(
db: Database,
context: InstallationContext,
raw: unknown,
): Promise<ToolResult<unknown>> {
requireScope(context, "muster_export_audit");
requireCapability(context.subject, "audit.export");

let args: z.infer<typeof AuditExportSchema>;
try {
args = AuditExportSchema.parse(raw ?? {});
} catch (error) {
throw new McpToolError(
"invalid_input",
error instanceof Error ? error.message : "Invalid audit export request.",
);
}

const actions = args.actions ?? [
"mcp.tool.invoked",
"integration.action.approval_requested",
"knowledge.proposed",
];
const conditions = [
eq(schema.auditEvents.organisationId, context.subject.organisationId),
inArray(schema.auditEvents.action, actions),
];
if (args.since)
conditions.push(gte(schema.auditEvents.createdAt, new Date(args.since)));
if (args.until)
conditions.push(lte(schema.auditEvents.createdAt, new Date(args.until)));

const rows = await db
.select({
id: schema.auditEvents.id,
sequence: schema.auditEvents.sequence,
actorId: schema.auditEvents.actorId,
actorType: schema.auditEvents.actorType,
action: schema.auditEvents.action,
targetType: schema.auditEvents.targetType,
targetId: schema.auditEvents.targetId,
metadata: schema.auditEvents.metadata,
traceId: schema.auditEvents.traceId,
createdAt: schema.auditEvents.createdAt,
eventHash: schema.auditEvents.eventHash,
previousHash: schema.auditEvents.previousHash,
})
.from(schema.auditEvents)
.where(and(...conditions))
.orderBy(desc(schema.auditEvents.sequence))
.limit(args.limit);

return {
payload: {
records: rows.map((row) => ({
id: row.id,
sequence: row.sequence,
actorId: row.actorId,
actorType: row.actorType,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
metadata: redactMetadata(row.metadata),
traceId: row.traceId,
createdAt: row.createdAt,
eventHash: row.eventHash,
previousHash: row.previousHash,
})),
limit: args.limit,
truncated: rows.length === args.limit,
includesPrivateReasoning: false,
includesChainOfThought: false,
replay: {
mode: "recorded_results_only",
mayRepeatExternalActions: false,
},
evaluationHints: [
"tenant_isolation",
"schema_compliance",
"approval_behavior",
"evidence_citation",
"injection_resistance",
"unsupported_claims",
],
},
evidenceRefs: rows.map((r) => r.id),
};
}
47 changes: 47 additions & 0 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
proposeKnowledge,
searchKnowledge,
} from "./knowledge.ts";
import { exportAudit, listInvocations } from "./observability.ts";
import {
getKelpieCase,
getStatus,
Expand Down Expand Up @@ -304,5 +305,51 @@ export function createMusterMcpServer(deps: McpServerDeps) {
),
);


server.registerTool(
"muster_list_invocations",
{
description:
"List recent organisation-scoped MCP tool invocations from the audit log. No private reasoning; recorded hashes and outcomes only.",
inputSchema: {
limit: z.number().int().min(1).max(50).default(20),
tool: z.string().trim().min(1).max(100).optional(),
},
},
async ({ limit, tool }) =>
invoke("muster_list_invocations", () =>
listInvocations(deps.db, deps.context, { limit, tool }),
),
);

server.registerTool(
"muster_export_audit",
{
description:
"Bounded audit export for evaluation. Replay uses recorded results only and must not repeat external actions. No chain-of-thought.",
inputSchema: {
limit: z.number().int().min(1).max(100).default(25),
since: z.string().datetime().optional(),
until: z.string().datetime().optional(),
actions: z
.array(
z.enum([
"mcp.tool.invoked",
"integration.action.approval_requested",
"integration.action.queued",
"connector.query.queued",
"knowledge.proposed",
]),
)
.max(10)
.optional(),
},
},
async (args) =>
invoke("muster_export_audit", () =>
exportAudit(deps.db, deps.context, args),
),
);

return server;
}
Loading
Loading