@marchward/mcp-server 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Marchward MCP Server — Tool definitions
3
+ *
4
+ * Each tool maps to a Marchward API capability. These tools let
5
+ * AI coding agents (Claude, Cursor, Copilot, etc.):
6
+ * 1. Check governance before taking actions (marchward_authorize)
7
+ * 2. Execute actions through Marchward's credential gateway (marchward_execute)
8
+ * 3. Register and manage agents (marchward_register_agent, marchward_list_agents)
9
+ * 4. Inspect decisions (marchward_get_decisions)
10
+ * 5. Check governance coverage (marchward_check_coverage)
11
+ * 6. Bind policies to agents (marchward_bind_policy)
12
+ *
13
+ * Tool names are marchward_* (stable as of v0.2.0). MCP clients discover
14
+ * tools dynamically at runtime; the names are not dual-listed because
15
+ * duplicate tools degrade agent tool-selection.
16
+ */
17
+
18
+ import { z } from "zod";
19
+ import { MarchwardAPIClient } from "./api-client.js";
20
+
21
+ // ─── Tool Schemas ───────────────────────────────────────────────
22
+
23
+ export const TOOL_DEFINITIONS = {
24
+ marchward_authorize: {
25
+ name: "marchward_authorize",
26
+ description:
27
+ "Check whether an action is allowed BEFORE the agent takes it. Call this first for any consequential or irreversible action (deploying, committing, publishing, deleting, moving money, calling an external API). Returns ALLOW, BLOCK, ESCALATE, or ALLOW_WITH_CONDITIONS, and writes a tamper-evident audit record. The agent and policy are resolved from your API key, so you usually only pass toolName.",
28
+ inputSchema: z.object({
29
+ toolName: z
30
+ .string()
31
+ .describe(
32
+ "Name of the tool/action being authorized (e.g. 'github_commit', 'railway_deploy', 'notion_update')",
33
+ ),
34
+ arguments: z
35
+ .record(z.unknown())
36
+ .optional()
37
+ .describe("Arguments/parameters for the tool call"),
38
+ policyBundleId: z
39
+ .string()
40
+ .optional()
41
+ .describe("ID of the policy bundle to evaluate against (auto-resolved from API key → agent → policy if omitted)"),
42
+ agentId: z
43
+ .string()
44
+ .optional()
45
+ .describe("Agent ID (auto-resolved from API key if omitted)"),
46
+ mode: z
47
+ .enum(["HIC", "HITL", "HOTL"])
48
+ .optional()
49
+ .describe(
50
+ "Governance mode: HIC (human-in-command), HITL (human-in-the-loop), HOTL (human-on-the-loop)",
51
+ ),
52
+ context: z
53
+ .record(z.unknown())
54
+ .optional()
55
+ .describe("Additional context for policy evaluation"),
56
+ }),
57
+ },
58
+
59
+ marchward_execute: {
60
+ name: "marchward_execute",
61
+ description:
62
+ "Run an action through Marchward's credential-mediated gateway. Marchward evaluates policy, injects the real downstream credential server-side (the agent never holds it), enforces the cost cap and approval gates, then makes the call and records it. Use this for any call to a service whose credentials Marchward manages (GitHub, Stripe, Notion, etc.).",
63
+ inputSchema: z.object({
64
+ toolName: z
65
+ .string()
66
+ .describe("Name of the tool/action being executed"),
67
+ arguments: z
68
+ .record(z.unknown())
69
+ .optional()
70
+ .describe("Arguments for the tool call"),
71
+ policyBundleId: z
72
+ .string()
73
+ .describe("Policy bundle ID to evaluate against"),
74
+ agentId: z.string().describe("Agent ID making the request"),
75
+ service: z
76
+ .string()
77
+ .describe(
78
+ "Service name for credential injection (e.g. 'github', 'railway', 'notion')",
79
+ ),
80
+ downstream: z.object({
81
+ url: z.string().describe("Full URL for the downstream API call"),
82
+ method: z
83
+ .enum(["GET", "POST", "PUT", "PATCH", "DELETE"])
84
+ .describe("HTTP method"),
85
+ headers: z
86
+ .record(z.string())
87
+ .optional()
88
+ .describe("Additional headers"),
89
+ body: z.unknown().optional().describe("Request body"),
90
+ }),
91
+ mode: z.enum(["HIC", "HITL", "HOTL"]).optional(),
92
+ }),
93
+ },
94
+
95
+ marchward_register_agent: {
96
+ name: "marchward_register_agent",
97
+ description:
98
+ "Register a new agent with Marchward so it is governed from its first action. Returns the agent record and its Marchward API key — the only credential the agent should hold. Call this when building or deploying a new agent.",
99
+ inputSchema: z.object({
100
+ name: z.string().describe("Human-readable name for the agent"),
101
+ description: z
102
+ .string()
103
+ .optional()
104
+ .describe("Description of what the agent does"),
105
+ agentId: z
106
+ .string()
107
+ .optional()
108
+ .describe(
109
+ "Custom agent ID (auto-generated from name if omitted)",
110
+ ),
111
+ }),
112
+ },
113
+
114
+ marchward_list_agents: {
115
+ name: "marchward_list_agents",
116
+ description:
117
+ "List the agents registered under your account, with their IDs, status, last-seen time, and bound policy. Use to find an agentId or check what is under governance.",
118
+ inputSchema: z.object({}),
119
+ },
120
+
121
+ marchward_get_decisions: {
122
+ name: "marchward_get_decisions",
123
+ description:
124
+ "Review recent governance decisions (the audit trail). Filter by agent, tool name, or outcome (ALLOW/BLOCK/ESCALATE). Use to verify governance is active or to investigate what an agent actually did.",
125
+ inputSchema: z.object({
126
+ agentId: z
127
+ .string()
128
+ .optional()
129
+ .describe("Filter decisions by agent ID"),
130
+ toolName: z
131
+ .string()
132
+ .optional()
133
+ .describe("Filter decisions by tool name"),
134
+ result: z
135
+ .enum(["ALLOW", "BLOCK", "ESCALATE", "ALLOW_WITH_CONDITIONS"])
136
+ .optional()
137
+ .describe("Filter by decision outcome"),
138
+ limit: z
139
+ .number()
140
+ .optional()
141
+ .default(20)
142
+ .describe("Number of decisions to return (default 20)"),
143
+ }),
144
+ },
145
+
146
+ marchward_check_coverage: {
147
+ name: "marchward_check_coverage",
148
+ description:
149
+ "Find governance gaps for an agent by comparing its declared tools against the tools it has actually called. Returns a coverage percentage and the uncovered tools, so ungoverned actions surface before they cause harm. This is the 'Dependabot for governance'.",
150
+ inputSchema: z.object({
151
+ agentId: z.string().describe("Agent ID to check coverage for"),
152
+ }),
153
+ },
154
+
155
+ marchward_bind_policy: {
156
+ name: "marchward_bind_policy",
157
+ description:
158
+ "Attach a governance policy to an agent. After binding, every authorize and execute call from that agent is evaluated against this policy.",
159
+ inputSchema: z.object({
160
+ agentId: z.string().describe("Agent ID to bind the policy to"),
161
+ policyBundleId: z.string().describe("Policy bundle ID to bind"),
162
+ }),
163
+ },
164
+ } as const;
165
+
166
+ // ─── Tool Handlers ──────────────────────────────────────────────
167
+
168
+ export function createToolHandlers(client: MarchwardAPIClient) {
169
+ return {
170
+ async marchward_authorize(args: z.infer<typeof TOOL_DEFINITIONS.marchward_authorize.inputSchema>) {
171
+ const result = await client.authorize(args);
172
+ const decision = (result as any).result ?? (result as any).decision ?? "UNKNOWN";
173
+ const decisionId = (result as any).decisionId ?? "";
174
+ const explanation = (result as any).explanation ?? [];
175
+
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text" as const,
180
+ text: JSON.stringify(
181
+ {
182
+ decision,
183
+ decisionId,
184
+ explanation,
185
+ toolName: args.toolName,
186
+ ...(args.policyBundleId ? { policyBundleId: args.policyBundleId } : {}),
187
+ full: result,
188
+ },
189
+ null,
190
+ 2,
191
+ ),
192
+ },
193
+ ],
194
+ };
195
+ },
196
+
197
+ async marchward_execute(args: z.infer<typeof TOOL_DEFINITIONS.marchward_execute.inputSchema>) {
198
+ const result = await client.execute(args);
199
+ return {
200
+ content: [
201
+ {
202
+ type: "text" as const,
203
+ text: JSON.stringify(result, null, 2),
204
+ },
205
+ ],
206
+ };
207
+ },
208
+
209
+ async marchward_register_agent(args: z.infer<typeof TOOL_DEFINITIONS.marchward_register_agent.inputSchema>) {
210
+ const result = await client.createAgent(args);
211
+ return {
212
+ content: [
213
+ {
214
+ type: "text" as const,
215
+ text: JSON.stringify(
216
+ {
217
+ message: `Agent "${args.name}" registered successfully.`,
218
+ ...result,
219
+ },
220
+ null,
221
+ 2,
222
+ ),
223
+ },
224
+ ],
225
+ };
226
+ },
227
+
228
+ async marchward_list_agents(_args: z.infer<typeof TOOL_DEFINITIONS.marchward_list_agents.inputSchema>) {
229
+ const result = await client.listAgents();
230
+ return {
231
+ content: [
232
+ {
233
+ type: "text" as const,
234
+ text: JSON.stringify(result, null, 2),
235
+ },
236
+ ],
237
+ };
238
+ },
239
+
240
+ async marchward_get_decisions(args: z.infer<typeof TOOL_DEFINITIONS.marchward_get_decisions.inputSchema>) {
241
+ const result = await client.listDecisions(args);
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text" as const,
246
+ text: JSON.stringify(result, null, 2),
247
+ },
248
+ ],
249
+ };
250
+ },
251
+
252
+ async marchward_check_coverage(args: z.infer<typeof TOOL_DEFINITIONS.marchward_check_coverage.inputSchema>) {
253
+ const result = await client.checkCoverage(args.agentId);
254
+ return {
255
+ content: [
256
+ {
257
+ type: "text" as const,
258
+ text: JSON.stringify(result, null, 2),
259
+ },
260
+ ],
261
+ };
262
+ },
263
+
264
+ async marchward_bind_policy(args: z.infer<typeof TOOL_DEFINITIONS.marchward_bind_policy.inputSchema>) {
265
+ const result = await client.bindPolicy(
266
+ args.agentId,
267
+ args.policyBundleId,
268
+ );
269
+ return {
270
+ content: [
271
+ {
272
+ type: "text" as const,
273
+ text: JSON.stringify(
274
+ {
275
+ message: `Policy "${args.policyBundleId}" bound to agent "${args.agentId}".`,
276
+ ...result,
277
+ },
278
+ null,
279
+ 2,
280
+ ),
281
+ },
282
+ ],
283
+ };
284
+ },
285
+ };
286
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Lightweight Marchward API client for the MCP server.
3
+ *
4
+ * We don't import the full SDK to keep dependencies minimal.
5
+ * This makes HTTP calls directly to the Marchward API.
6
+ */
7
+
8
+ export interface MarchwardConfig {
9
+ apiUrl: string;
10
+ apiKey: string;
11
+ timeout?: number;
12
+ }
13
+
14
+ export class MarchwardAPIClient {
15
+ private apiUrl: string;
16
+ private apiKey: string;
17
+ private timeout: number;
18
+
19
+ constructor(config: MarchwardConfig) {
20
+ this.apiUrl = config.apiUrl.replace(/\/$/, "");
21
+ this.apiKey = config.apiKey;
22
+ this.timeout = config.timeout ?? 10_000;
23
+ }
24
+
25
+ private async request<T>(
26
+ method: string,
27
+ path: string,
28
+ body?: unknown,
29
+ ): Promise<T> {
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), this.timeout);
32
+
33
+ try {
34
+ const res = await fetch(`${this.apiUrl}${path}`, {
35
+ method,
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ Authorization: `Bearer ${this.apiKey}`,
39
+ },
40
+ body: body ? JSON.stringify(body) : undefined,
41
+ signal: controller.signal,
42
+ });
43
+
44
+ const data = await res.json();
45
+ if (!res.ok) {
46
+ throw new Error(
47
+ `Marchward API ${method} ${path} returned ${res.status}: ${JSON.stringify(data)}`,
48
+ );
49
+ }
50
+ return data as T;
51
+ } finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
55
+
56
+ // ─── Core Governance ──────────────────────────────────────────
57
+
58
+ async authorize(input: {
59
+ toolName: string;
60
+ arguments?: Record<string, unknown>;
61
+ policyBundleId?: string;
62
+ agentId?: string;
63
+ mode?: string;
64
+ context?: Record<string, unknown>;
65
+ signals?: Record<string, unknown>;
66
+ }) {
67
+ return this.request<Record<string, unknown>>("POST", "/v1/authorize", {
68
+ toolCall: {
69
+ toolName: input.toolName,
70
+ arguments: input.arguments ?? {},
71
+ },
72
+ // Only include policyBundle if explicitly provided — otherwise let
73
+ // the API auto-resolve from the API key → agent → policy chain.
74
+ policyBundle: input.policyBundleId ? { id: input.policyBundleId } : undefined,
75
+ agent: input.agentId ? { agentId: input.agentId } : undefined,
76
+ mode: input.mode,
77
+ context: input.context,
78
+ signals: input.signals,
79
+ });
80
+ }
81
+
82
+ async execute(input: {
83
+ toolName: string;
84
+ arguments?: Record<string, unknown>;
85
+ policyBundleId: string;
86
+ agentId: string;
87
+ service: string;
88
+ downstream: {
89
+ url: string;
90
+ method: string;
91
+ headers?: Record<string, string>;
92
+ body?: unknown;
93
+ };
94
+ mode?: string;
95
+ }) {
96
+ return this.request<Record<string, unknown>>("POST", "/v1/execute", {
97
+ toolCall: {
98
+ toolName: input.toolName,
99
+ arguments: input.arguments ?? {},
100
+ },
101
+ policyBundle: { id: input.policyBundleId },
102
+ agent: { agentId: input.agentId },
103
+ service: input.service,
104
+ downstream: input.downstream,
105
+ mode: input.mode,
106
+ });
107
+ }
108
+
109
+ // ─── Agent Registry ───────────────────────────────────────────
110
+
111
+ async createAgent(input: {
112
+ name: string;
113
+ description?: string;
114
+ agentId?: string;
115
+ }) {
116
+ return this.request<Record<string, unknown>>("POST", "/v1/agents", input);
117
+ }
118
+
119
+ async listAgents() {
120
+ return this.request<Record<string, unknown>>("GET", "/v1/agents");
121
+ }
122
+
123
+ async getAgent(agentId: string) {
124
+ return this.request<Record<string, unknown>>(
125
+ "GET",
126
+ `/v1/agents/${agentId}`,
127
+ );
128
+ }
129
+
130
+ async bindPolicy(agentId: string, policyBundleId: string) {
131
+ return this.request<Record<string, unknown>>(
132
+ "POST",
133
+ `/v1/agents/${agentId}/bind-policy`,
134
+ { policyBundleId },
135
+ );
136
+ }
137
+
138
+ // ─── Decisions ────────────────────────────────────────────────
139
+
140
+ async listDecisions(query?: {
141
+ agentId?: string;
142
+ toolName?: string;
143
+ result?: string;
144
+ limit?: number;
145
+ offset?: number;
146
+ }) {
147
+ const params = new URLSearchParams();
148
+ if (query?.agentId) params.set("agentId", query.agentId);
149
+ if (query?.toolName) params.set("toolName", query.toolName);
150
+ if (query?.result) params.set("result", query.result);
151
+ if (query?.limit) params.set("limit", String(query.limit));
152
+ if (query?.offset) params.set("offset", String(query.offset));
153
+
154
+ const qs = params.toString();
155
+ return this.request<Record<string, unknown>>(
156
+ "GET",
157
+ `/v1/decisions${qs ? `?${qs}` : ""}`,
158
+ );
159
+ }
160
+
161
+ // ─── Coverage Check ───────────────────────────────────────────
162
+
163
+ async checkCoverage(agentId: string) {
164
+ // Get agent's registered tools
165
+ const agent = await this.getAgent(agentId);
166
+ const registeredTools = (agent as any).tools ?? [];
167
+
168
+ // Get recent decisions for this agent
169
+ const decisions = await this.listDecisions({
170
+ agentId,
171
+ limit: 200,
172
+ });
173
+ const decisionList = (decisions as any).decisions ?? [];
174
+
175
+ // Find unique tool names that have produced decisions
176
+ const observedTools = new Set<string>();
177
+ for (const d of decisionList) {
178
+ if (d.toolName) observedTools.add(d.toolName);
179
+ }
180
+
181
+ // Compare declared vs observed
182
+ const declaredToolNames = registeredTools.map(
183
+ (t: any) => t.name ?? t.toolName ?? t,
184
+ );
185
+ const covered = declaredToolNames.filter((t: string) =>
186
+ observedTools.has(t),
187
+ );
188
+ const uncovered = declaredToolNames.filter(
189
+ (t: string) => !observedTools.has(t),
190
+ );
191
+ const undeclaredButObserved = [...observedTools].filter(
192
+ (t) => !declaredToolNames.includes(t),
193
+ );
194
+
195
+ const totalDeclared = declaredToolNames.length;
196
+ const coveragePercent =
197
+ totalDeclared > 0
198
+ ? Math.round((covered.length / totalDeclared) * 100)
199
+ : 0;
200
+
201
+ return {
202
+ agentId,
203
+ totalDeclaredTools: totalDeclared,
204
+ totalObservedTools: observedTools.size,
205
+ coveragePercent,
206
+ coveredTools: covered,
207
+ uncoveredTools: uncovered,
208
+ undeclaredButObserved,
209
+ totalDecisionsAnalyzed: decisionList.length,
210
+ summary:
211
+ totalDeclared === 0
212
+ ? `Agent "${agentId}" has no declared tools. Cannot calculate coverage.`
213
+ : coveragePercent === 100
214
+ ? `Agent "${agentId}" has 100% governance coverage (${totalDeclared}/${totalDeclared} tools producing decisions).`
215
+ : `Agent "${agentId}" has ${coveragePercent}% governance coverage (${covered.length}/${totalDeclared} tools). Missing: ${uncovered.join(", ")}`,
216
+ };
217
+ }
218
+
219
+ // ─── Health ───────────────────────────────────────────────────
220
+
221
+ async health() {
222
+ return this.request<Record<string, unknown>>("GET", "/health");
223
+ }
224
+ }