@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.
- package/dist/packages/core/src/index.js +88 -0
- package/dist/workspace-mcp/src/index.js +32 -0
- package/dist/workspace-mcp/src/tests/isolation.test.js +22 -0
- package/dist/workspace-mcp/src/tools/context.js +32 -0
- package/dist/workspace-mcp/src/tools/daily.js +29 -0
- package/dist/workspace-mcp/src/tools/decision.js +37 -0
- package/dist/workspace-mcp/src/tools/memory.js +24 -0
- package/dist/workspace-mcp/src/tools/read.js +21 -0
- package/dist/workspace-mcp/src/tools/search.js +33 -0
- package/dist/workspace-mcp/src/tools/write.js +33 -0
- package/dist/workspace-mcp/src/utils.js +7 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|
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
|
+
}
|