@context-os/mcp 1.0.0

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,88 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ /**
5
+ * Discovers the workspace root by looking for root/soul.md in parent directories.
6
+ */
7
+ export function findWorkspaceRoot() {
8
+ let current = process.cwd();
9
+ const root = path.parse(current).root;
10
+ while (current !== root) {
11
+ if (fs.existsSync(path.join(current, "root", "soul.md"))) {
12
+ return fs.realpathSync(current);
13
+ }
14
+ current = path.dirname(current);
15
+ }
16
+ return fs.realpathSync(process.cwd()); // Fallback to CWD
17
+ }
18
+ export const workspaceRoot = findWorkspaceRoot();
19
+ /**
20
+ * Standard ContextOS "Buckets" for security isolation.
21
+ */
22
+ export const ALLOWED_BUCKETS = [
23
+ "projects",
24
+ "knowledge",
25
+ "schemas",
26
+ "archive",
27
+ "log",
28
+ "orgs",
29
+ "root"
30
+ ];
31
+ /**
32
+ * Validates that a path is within the workspace root and inside an allowed bucket.
33
+ */
34
+ export function validatePath(requestedPath) {
35
+ const resolvedPath = path.resolve(workspaceRoot, requestedPath);
36
+ let fullPath;
37
+ try {
38
+ fullPath = fs.realpathSync(resolvedPath);
39
+ }
40
+ catch (e) {
41
+ fullPath = resolvedPath;
42
+ }
43
+ const relativePath = path.relative(workspaceRoot, fullPath);
44
+ // Security check: must be within the workspace root
45
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
46
+ throw new Error(`Security violation: Path ${requestedPath} is outside the allowed ContextOS workspace root.`);
47
+ }
48
+ // Enterprise check: must be within an allowed bucket
49
+ const isAllowed = ALLOWED_BUCKETS.some(bucket => {
50
+ const bucketRoot = path.join(workspaceRoot, bucket);
51
+ const bucketRelative = path.relative(bucketRoot, fullPath);
52
+ return !bucketRelative.startsWith("..") && !path.isAbsolute(bucketRelative);
53
+ });
54
+ if (!isAllowed) {
55
+ throw new Error(`Security violation: Path ${requestedPath} is outside the allowed bucket (projects, orgs, knowledge, schemas, etc).`);
56
+ }
57
+ return { fullPath, relativePath };
58
+ }
59
+ /**
60
+ * Checks if a path is in a read-only bucket for agents.
61
+ */
62
+ export function isReadOnly(filePath) {
63
+ const { fullPath } = validatePath(filePath);
64
+ const readOnlyBuckets = ["knowledge", "schemas", "root"];
65
+ return readOnlyBuckets.some(bucket => {
66
+ const bucketRoot = path.join(workspaceRoot, bucket);
67
+ const bucketRelative = path.relative(bucketRoot, fullPath);
68
+ return !bucketRelative.startsWith("..") && !path.isAbsolute(bucketRelative);
69
+ });
70
+ }
71
+ /**
72
+ * Executes an atomic git transaction (Add + Commit).
73
+ */
74
+ export async function gitCommit(filePath, message) {
75
+ return new Promise((resolve, reject) => {
76
+ const add = spawn("git", ["add", filePath], { cwd: workspaceRoot });
77
+ add.on("close", (code) => {
78
+ if (code !== 0 && code !== null) {
79
+ return reject(new Error(`Git add failed with code ${code}`));
80
+ }
81
+ const commit = spawn("git", ["commit", "-m", message], { cwd: workspaceRoot });
82
+ commit.on("close", (code) => {
83
+ // If code is not 0, it might be "nothing to commit" which is fine for our tools
84
+ resolve();
85
+ });
86
+ });
87
+ });
88
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { registerReadTool } from "./tools/read.js";
5
+ import { registerWriteTool } from "./tools/write.js";
6
+ import { registerSearchTool } from "./tools/search.js";
7
+ import { registerContextTool } from "./tools/context.js";
8
+ import { registerDecisionTool } from "./tools/decision.js";
9
+ import { registerMemoryTool } from "./tools/memory.js";
10
+ import { registerDailyTool } from "./tools/daily.js";
11
+ async function main() {
12
+ const server = new McpServer({
13
+ name: "workspace-mcp",
14
+ version: "0.1.0",
15
+ });
16
+ // Register all tools
17
+ registerReadTool(server);
18
+ registerWriteTool(server);
19
+ registerSearchTool(server);
20
+ registerContextTool(server);
21
+ registerDecisionTool(server);
22
+ registerMemoryTool(server);
23
+ registerDailyTool(server);
24
+ // Connect via stdio
25
+ const transport = new StdioServerTransport();
26
+ await server.connect(transport);
27
+ console.error("Workspace MCP Server running on stdio");
28
+ }
29
+ main().catch((error) => {
30
+ console.error("MCP Server Error:", error);
31
+ process.exit(1);
32
+ });
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert";
2
+ import { validatePath, findWorkspaceRoot } from "../utils.js";
3
+ import path from "node:path";
4
+ describe("Security Isolation Tests", () => {
5
+ const workspaceRoot = findWorkspaceRoot();
6
+ it("should allow paths within projects directory", () => {
7
+ const validPath = path.join(workspaceRoot, "projects", "ContextOS", "memory.md");
8
+ assert.doesNotThrow(() => validatePath(validPath));
9
+ });
10
+ it("should block directory traversal attacks (..)", () => {
11
+ const maliciousPath = path.join(workspaceRoot, "projects", "ContextOS", "..", "..", "package.json");
12
+ assert.throws(() => validatePath(maliciousPath), /Security violation/);
13
+ });
14
+ it("should block access to root config files when in project context", () => {
15
+ const rootConfig = path.join(workspaceRoot, "package.json");
16
+ assert.throws(() => validatePath(rootConfig), /Security violation/);
17
+ });
18
+ it("should allow access to specific allowed root files (knowledge, schemas)", () => {
19
+ const knowledgePath = path.join(workspaceRoot, "knowledge", "domains", "ai-agents.md");
20
+ assert.doesNotThrow(() => validatePath(knowledgePath));
21
+ });
22
+ });
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { validatePath, handleToolError } from "../utils.js";
5
+ export function registerContextTool(server) {
6
+ server.tool("workspace_context", {
7
+ project: z.string().describe("The project name (e.g., ContextOS)")
8
+ }, async ({ project }) => {
9
+ try {
10
+ const { fullPath: projectDir } = validatePath(`projects/${project}`);
11
+ const filesToLoad = ["CONTEXT.md", "memory.md", "decisions.md", "tasks/active.md"];
12
+ let content = `# Context for Project: ${project}\n\n`;
13
+ for (const file of filesToLoad) {
14
+ const filePath = path.join(projectDir, file);
15
+ try {
16
+ const data = await fs.readFile(filePath, "utf-8");
17
+ content += `## ${file}\n\n${data}\n---\n\n`;
18
+ }
19
+ catch (fileError) {
20
+ content += `## ${file}\n\n(File not found)\n---\n\n`;
21
+ }
22
+ }
23
+ return {
24
+ content: [{ type: "text", text: content }],
25
+ isError: false
26
+ };
27
+ }
28
+ catch (error) {
29
+ return handleToolError(error);
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { validatePath, gitCommit, handleToolError } from "../utils.js";
5
+ export function registerDailyTool(server) {
6
+ server.tool("workspace_daily_update", {
7
+ project: z.string().describe("Project name"),
8
+ content: z.string().describe("Content to append to today's daily log")
9
+ }, async ({ project, content }) => {
10
+ try {
11
+ const { fullPath: dailyDir } = validatePath(`projects/${project}/daily`);
12
+ const date = new Date().toISOString().split("T")[0];
13
+ const dailyLogPath = path.join(dailyDir, `${date}.md`);
14
+ // Ensure daily directory exists
15
+ await fs.mkdir(dailyDir, { recursive: true });
16
+ const timestamp = new Date().toLocaleTimeString();
17
+ const entry = `\n### [${timestamp}]\n\n${content}\n`;
18
+ await fs.appendFile(dailyLogPath, entry, "utf-8");
19
+ await gitCommit(dailyLogPath, `feat(mcp): daily log entry for ${date}`);
20
+ return {
21
+ content: [{ type: "text", text: `Updated today's daily log: ${date}.md` }],
22
+ isError: false
23
+ };
24
+ }
25
+ catch (error) {
26
+ return handleToolError(error);
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ import fs from "fs/promises";
3
+ import { validatePath, gitCommit, handleToolError } from "../utils.js";
4
+ export function registerDecisionTool(server) {
5
+ server.tool("workspace_log_decision", {
6
+ project: z.string().describe("Project name"),
7
+ title: z.string().describe("Decision title"),
8
+ context: z.string().describe("Context of the decision"),
9
+ decision: z.string().describe("The decision made"),
10
+ rationale: z.string().describe("Rationale for the decision")
11
+ }, async ({ project, title, context, decision, rationale }) => {
12
+ try {
13
+ const { fullPath: projectDir } = validatePath(`projects/${project}`);
14
+ const decisionsPath = `${projectDir}/decisions.md`;
15
+ const date = new Date().toISOString().split("T")[0];
16
+ const adrId = `ADR-${Math.floor(Math.random() * 10000).toString().padStart(4, "0")}`;
17
+ const adrContent = `
18
+ ## [${adrId}] ${title}
19
+
20
+ - **Date**: ${date}
21
+ - **Status**: Accepted
22
+ - **Context**: ${context}
23
+ - **Decision**: ${decision}
24
+ - **Rationale**: ${rationale}
25
+ \n---\n`;
26
+ await fs.appendFile(decisionsPath, adrContent, "utf-8");
27
+ await gitCommit(decisionsPath, `feat(mcp): log decision ${adrId}: ${title}`);
28
+ return {
29
+ content: [{ type: "text", text: `Logged decision ${adrId} in ${project}/decisions.md` }],
30
+ isError: false
31
+ };
32
+ }
33
+ catch (error) {
34
+ return handleToolError(error);
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ import fs from "fs/promises";
3
+ import { validatePath, gitCommit, handleToolError } from "../utils.js";
4
+ export function registerMemoryTool(server) {
5
+ server.tool("workspace_memory_update", {
6
+ project: z.string().describe("Project name"),
7
+ content: z.string().describe("Updated memory content (Markdown format)")
8
+ }, async ({ project, content }) => {
9
+ try {
10
+ const { fullPath: memoryPath } = validatePath(`projects/${project}/memory.md`);
11
+ const date = new Date().toISOString().split("T")[0];
12
+ const fullMemoryContent = `# Project Memory: ${project}\n\nLast Updated: ${date}\n\n${content}\n`;
13
+ await fs.writeFile(memoryPath, fullMemoryContent, "utf-8");
14
+ await gitCommit(memoryPath, `feat(mcp): update context memory for ${project}`);
15
+ return {
16
+ content: [{ type: "text", text: `Updated memory for ${project}` }],
17
+ isError: false
18
+ };
19
+ }
20
+ catch (error) {
21
+ return handleToolError(error);
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import fs from "fs/promises";
3
+ import { validatePath, handleToolError } from "../utils.js";
4
+ export function registerReadTool(server) {
5
+ server.tool("workspace_read", {
6
+ path: z.string().describe("Relative path to the file"),
7
+ scope: z.enum(["root", "org", "project"]).describe("Access scope for protection")
8
+ }, async ({ path: filePath, scope }) => {
9
+ try {
10
+ const { fullPath } = validatePath(`${scope}/${filePath}`);
11
+ const data = await fs.readFile(fullPath, "utf-8");
12
+ return {
13
+ content: [{ type: "text", text: data }],
14
+ isError: false
15
+ };
16
+ }
17
+ catch (error) {
18
+ return handleToolError(error);
19
+ }
20
+ });
21
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { execFile } from "child_process";
3
+ import { promisify } from "util";
4
+ import { validatePath, handleToolError } from "../utils.js";
5
+ const execFileAsync = promisify(execFile);
6
+ export function registerSearchTool(server) {
7
+ server.tool("workspace_search", {
8
+ query: z.string().describe("Search query string"),
9
+ scope: z.string().optional().describe("Search scope (e.g., projects, knowledge, or specific project name)")
10
+ }, async ({ query, scope }) => {
11
+ try {
12
+ const target = scope === "root" ? "." : scope || "projects";
13
+ const { fullPath: baseDir } = validatePath(target);
14
+ // Use grep -rnI via execFile (no shell interpolation)
15
+ const { stdout } = await execFileAsync("grep", ["-rnIE", query, "."], { cwd: baseDir });
16
+ const results = stdout.split("\n").slice(0, 50).join("\n");
17
+ return {
18
+ content: [{ type: "text", text: results || "No results found." }],
19
+ isError: false
20
+ };
21
+ }
22
+ catch (error) {
23
+ // If grep fails (no results), return a friendly message
24
+ if (error.code === 1) {
25
+ return {
26
+ content: [{ type: "text", text: "No results found." }],
27
+ isError: false
28
+ };
29
+ }
30
+ return handleToolError(error);
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import fs from "fs/promises";
3
+ import { validatePath, gitCommit, handleToolError } from "../utils.js";
4
+ export function registerWriteTool(server) {
5
+ server.tool("workspace_write", {
6
+ path: z.string().describe("Relative path to the file"),
7
+ content: z.string().describe("Content to write"),
8
+ mode: z.enum(["append", "replace"]).describe("Write mode")
9
+ }, async ({ path: filePath, content, mode }) => {
10
+ try {
11
+ const { fullPath, relativePath } = validatePath(filePath);
12
+ const { isReadOnly } = await import("../utils.js");
13
+ if (isReadOnly(fullPath)) {
14
+ throw new Error(`Path ${filePath} is in a read-only bucket (knowledge/schemas). Use specific extraction tools to update knowledge.`);
15
+ }
16
+ if (mode === "append") {
17
+ await fs.appendFile(fullPath, "\n" + content, "utf-8");
18
+ }
19
+ else {
20
+ await fs.writeFile(fullPath, content, "utf-8");
21
+ }
22
+ // Secure auto-commit via GitQueue
23
+ await gitCommit(fullPath, `chore(mcp): update ${relativePath}`);
24
+ return {
25
+ content: [{ type: "text", text: `Successfully ${mode}d to ${filePath}` }],
26
+ isError: false
27
+ };
28
+ }
29
+ catch (error) {
30
+ return handleToolError(error);
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,7 @@
1
+ export { findWorkspaceRoot, workspaceRoot, ALLOWED_BUCKETS, validatePath, isReadOnly, gitCommit } from "@context-os/core";
2
+ export function handleToolError(error) {
3
+ return {
4
+ content: [{ type: "text", text: `Error: ${error.message}` }],
5
+ isError: true
6
+ };
7
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@context-os/mcp",
3
+ "version": "1.0.0",
4
+ "description": "Model Context Protocol server for ContextOS integration",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "dist/index.js",
10
+ "bin": {
11
+ "context-os-mcp": "./dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "watch": "tsc -w",
23
+ "start": "node dist/index.js",
24
+ "test": "npm run build && PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin npx mocha dist/tests/**/*.test.js"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.6.0",
28
+ "@context-os/core": "1.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/chai": "^5.2.3",
32
+ "@types/mocha": "^10.0.10",
33
+ "@types/node": "^22.13.10",
34
+ "chai": "^6.2.2",
35
+ "mocha": "^11.7.5",
36
+ "ts-node": "^10.9.2",
37
+ "typescript": "^5.8.2"
38
+ }
39
+ }