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
10 changes: 10 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ export function run() {
mode: options.mode,
baseline: options.baseline,
repoRoot: options.repo,
files: options.files ? JSON.parse(options.files) : undefined,
message: options.commitMessage,
branch: options.branch,
pr: options.pr,
surface: "cli",
});

outputActionResult(tool.name, result, format, quiet);
Expand Down Expand Up @@ -543,6 +548,11 @@ export function run() {
mode: options.mode,
baseline: options.baseline,
repoRoot: options.repo,
files: options.files ? JSON.parse(options.files) : undefined,
message: options.commitMessage,
branch: options.branch,
pr: options.pr,
surface: "cli",
});
const ok = !isActionError(result);
console.log(JSON.stringify(wrapToolJson(tool.name, result, ok)));
Expand Down
170 changes: 170 additions & 0 deletions src/core/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { buildBM25Index, searchBM25 } from "../search/bm25.js";
import { buildIndex, loadIndex, saveIndex, INDEX_VERSION } from "../index/buildIndex.js";
import { ensureBaselineRepo, getSessionSha } from "../baseline/ensureBaselineRepo.js";
import { ACTION_NAMES } from "./tool-registry.js";
import { validateFiles } from "../utils/writeValidation.js";
import { parseBaselineUrl, getFileSha, writeFile, getDefaultBranch, getBranchSha, branchExists, createBranch, createPR } from "../utils/githubApi.js";
import { readFileSync, existsSync } from "fs";
import { createRequire } from "module";
import matter from "gray-matter";
Expand Down Expand Up @@ -478,6 +480,174 @@ export async function handleAction(params) {
};
}

case "write": {
// Write files to GitHub repo via Contents API
// Validates against governance constraints, commits to default branch or branch
const { files, message, pr, repo: providedRepo } = params;
let { branch } = params;

// Validate input
if (!files || !Array.isArray(files) || files.length === 0) {
return {
action: "write",
result: { error: "No files provided. Expected array of {path, content} objects." },
assistant_text: "No files provided. Please provide an array of files with path and content.",
debug: makeDebug(),
};
}

if (!message) {
return {
action: "write",
result: { error: "Commit message required." },
assistant_text: "Commit message is required.",
debug: makeDebug(),
};
}

if (pr && !branch) {
branch = `oddkit-write/${Date.now()}`;
}

// Determine target repo: explicit repo param takes precedence over baseline URL
let owner, repoName;

if (providedRepo) {
const parts = providedRepo.split("/");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return {
action: "write",
result: { error: `Invalid repo format: "${providedRepo}". Expected "owner/repo".` },
assistant_text: `Invalid repo format: "${providedRepo}". Expected "owner/repo".`,
debug: makeDebug(),
};
}
owner = parts[0];
repoName = parts[1];
} else {
const baselineUrl = baseline || process.env.ODDKIT_BASELINE;
if (!baselineUrl) {
return {
action: "write",
result: { error: "No target repo specified. Provide repo param or set ODDKIT_BASELINE." },
assistant_text: "Write requires an explicit target repo. Provide the repo parameter (owner/repo) or set ODDKIT_BASELINE.",
debug: makeDebug(),
};
}
try {
const parsed = parseBaselineUrl(baselineUrl);
owner = parsed.owner;
repoName = parsed.repo;
} catch (err) {
return {
action: "write",
result: { error: err.message },
assistant_text: `Failed to parse baseline URL: ${err.message}. Set ODDKIT_BASELINE or use repo parameter.`,
debug: makeDebug(),
};
}
}

// Validate files against governance constraints
const validation = validateFiles(files);

// Block writes if any path is unsafe (traversal sequences)
const unsafePaths = validation.results
.filter(r => r.checks.some(c => c.name === "path_safe" && !c.passed))
.map(r => r.file);
if (unsafePaths.length > 0) {
return {
action: "write",
result: { error: `Unsafe path(s) detected: ${unsafePaths.join(", ")}`, validation },
assistant_text: `Write blocked: path traversal detected in ${unsafePaths.join(", ")}. Remove '..' or '~' sequences.`,
debug: makeDebug(),
};
}

// Add provenance to commit message
const provenanceFooter = `\n---\noddkit-surface: ${params.surface || "mcp"}\noddkit-timestamp: ${new Date().toISOString()}`;
Comment thread
cursor[bot] marked this conversation as resolved.
const commitMessage = message + provenanceFooter;
Comment thread
cursor[bot] marked this conversation as resolved.

try {
// Get default branch if no branch specified
let targetBranch = branch;
let defaultBranch = null;
let status = "committed";

if (!targetBranch) {
defaultBranch = await getDefaultBranch(owner, repoName);
targetBranch = defaultBranch;
} else {
// Check if branch exists, create if not
const exists = await branchExists(owner, repoName, targetBranch);
if (!exists) {
defaultBranch = await getDefaultBranch(owner, repoName);
const sourceSha = await getBranchSha(owner, repoName, defaultBranch);
await createBranch(owner, repoName, targetBranch, sourceSha);
status = "branch_created";
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

// Write each file
const filesWritten = [];
const writeResults = [];

for (const file of files) {
// Get current SHA if file exists (required for updates)
const sha = await getFileSha(owner, repoName, file.path, targetBranch);

const result = await writeFile(
owner,
repoName,
file.path,
file.content,
commitMessage,
targetBranch,
sha
);

filesWritten.push(file.path);
writeResults.push(result);
}

// Handle PR if requested
let prResult = null;
if (pr && branch) {
Comment thread
cursor[bot] marked this conversation as resolved.
const prBody = `Files:\n${files.map(f => `- ${f.path}`).join("\n")}\n\n---\nWritten via oddkit_write`;
const baseBranch = defaultBranch || await getDefaultBranch(owner, repoName);
prResult = await createPR(owner, repoName, message, prBody, branch, baseBranch);
status = "pr_opened";
}

return {
action: "write",
result: {
status,
commit_sha: writeResults[writeResults.length - 1]?.commit_sha,
commit_url: writeResults[writeResults.length - 1]?.commit_url,
branch: targetBranch,
files_written: filesWritten,
pr_url: prResult?.pr_url,
pr_number: prResult?.pr_number,
validation,
},
assistant_text: `Successfully wrote ${filesWritten.length} file(s) to ${owner}/${repoName} on branch ${targetBranch}. Commit: ${writeResults[writeResults.length - 1]?.commit_url}${prResult ? `\nPR: ${prResult.pr_url}` : ""}${!validation.passed ? "\n\nValidation warnings: " + validation.results.map(r => r.checks.filter(c => !c.passed).map(c => c.name).join(", ")).filter(x => x).join("; ") : ""}`,
debug: makeDebug({ files_count: files.length, validation_passed: validation.passed }),
};

} catch (err) {
return {
action: "write",
result: {
error: err.message,
validation,
},
assistant_text: `Write failed: ${err.message}${!validation.passed ? "\nValidation warnings: " + validation.results.map(r => r.checks.filter(c => !c.passed).map(c => c.name).join(", ")).filter(x => x).join("; ") : ""}`,
debug: makeDebug({ files_count: files.length }),
};
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

default:
return {
action: "error",
Expand Down
38 changes: 36 additions & 2 deletions src/core/tool-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ Use when:
required: ["action", "input"],
},
annotations: {
destructiveHint: false,
idempotentHint: true,
destructiveHint: true,
idempotentHint: false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Orchestrator annotations mark all actions as destructive

High Severity

The orchestrator tool's annotations were changed from destructiveHint: false / idempotentHint: true to destructiveHint: true / idempotentHint: false. This affects ALL actions routed through the oddkit orchestrator tool (search, get, orient, catalog, etc.), not just the new write action. MCP hosts use these hints to decide whether to auto-approve tool calls or show confirmation dialogs. This change causes unnecessary user confirmation prompts for the ~11 read-only, idempotent actions that existed before this PR.

Fix in Cursor Fix in Web

openWorldHint: true,
},
};
Expand Down Expand Up @@ -265,6 +265,40 @@ export const TOOLS = [
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
cliFlags: {},
},
{
name: "write",
mcpName: "oddkit_write",
description: "Write files to the GitHub repo. Accepts file path(s), content, commit message. Validates against governance constraints. Supports branches and PRs optionally.",
inputSchema: {
type: "object",
properties: {
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string", description: "Repo-relative file path (e.g., docs/decisions/D0017.md)" },
content: { type: "string", description: "File content (UTF-8 text)" },
},
required: ["path", "content"],
},
description: "Array of files to write",
},
message: { type: "string", description: "Commit message" },
branch: { type: "string", description: "Optional: target branch. If omitted, writes to default branch." },
pr: { type: "boolean", description: "Optional: if true, opens a PR from branch to default branch." },
repo: { type: "string", description: "Optional: GitHub repo (owner/repo). Defaults to baseline repo." },
},
required: ["files", "message"],
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
cliFlags: {
files: { flag: "--files <json>", description: "JSON array of {path, content} objects" },
commitMessage: { flag: "--commit-message <text>", description: "Commit message", required: true },
branch: { flag: "--branch <name>", description: "Optional branch name" },
pr: { flag: "--pr", description: "Open PR after commit" },
},
Comment thread
cursor[bot] marked this conversation as resolved.
},
Comment thread
cursor[bot] marked this conversation as resolved.
];

// ──────────────────────────────────────────────────────────────────────────────
Expand Down
13 changes: 12 additions & 1 deletion src/mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ async function main() {
state: args.state,
baseline: args.canon_url,
include_metadata: args.include_metadata,
files: args.files,
message: args.message,
branch: args.branch,
pr: args.pr,
repo: args.repo,
surface: "mcp",
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
Expand All @@ -209,7 +215,12 @@ async function main() {
canon_url: args.canon_url,
baseline: args.canon_url,
include_metadata: args.include_metadata,
// No state for individual tools
files: args.files,
message: args.message,
branch: args.branch,
pr: args.pr,
repo: args.repo,
surface: "mcp",
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
Expand Down
Loading