oioxo-mcp 0.1.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,122 @@
1
+ /**
2
+ * OIOXO MCP server — exposes the on-device context engine to ANY MCP-capable
3
+ * agent (Claude Code, Copilot, Cursor, Windsurf, …) over stdio.
4
+ *
5
+ * Tool design notes:
6
+ * - `get_context` is THE tool: descriptions tell the agent to call it BEFORE
7
+ * reading files, which is what makes OIOXO fire first on every prompt.
8
+ * - Retrieval tools are gated by the OIOXO plan via the server-authoritative
9
+ * save-tokens meter (gate/account.ts): anon → sign in, free → monthly
10
+ * allowance, Pro → unlimited. Memory tools stay ungated (they save no
11
+ * tokens; they make the engine sticky).
12
+ * - Code is indexed in-memory and NEVER uploaded. Only the saved-token COUNT
13
+ * is reported to oioxo.com for metering.
14
+ */
15
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
+ import { z } from 'zod';
18
+ import { loadProject } from '../core/files.js';
19
+ import { Bm25Index } from '../core/bm25.js';
20
+ import { buildCapsule } from '../core/capsule.js';
21
+ import { buildGraph } from '../core/code-graph.js';
22
+ import { fileSkeleton } from '../core/skeleton.js';
23
+ import { readProjectContext, rememberFact, contextBlock } from '../core/memory.js';
24
+ import { saveMeter, isLoggedIn, LOGIN_HINT, UPGRADE_URL } from '../gate/account.js';
25
+ const VERSION = '0.1.0';
26
+ const REINDEX_MS = 60_000;
27
+ const text = (t) => ({ content: [{ type: 'text', text: t }] });
28
+ async function checkGate() {
29
+ if (!(await isLoggedIn())) {
30
+ return {
31
+ ok: false,
32
+ message: `OIOXO context engine is linked to an OIOXO account.\n${LOGIN_HINT}\n` +
33
+ `Free accounts include a monthly context allowance; OIOXO Pro is unlimited → ${UPGRADE_URL}`,
34
+ };
35
+ }
36
+ const state = await saveMeter('check');
37
+ if (!state) {
38
+ // Server unreachable → fail toward the gate, with an honest message.
39
+ return { ok: false, message: 'OIOXO could not verify your plan (oioxo.com unreachable). Retrieval is paused until the connection returns.' };
40
+ }
41
+ if (!state.optimize) {
42
+ if (state.tier === 'anon') {
43
+ return { ok: false, message: `Your OIOXO session expired. ${LOGIN_HINT}` };
44
+ }
45
+ return {
46
+ ok: false,
47
+ message: `Your OIOXO Free monthly context allowance is used (${state.savedThisMonth.toLocaleString()} tokens saved this month).\n` +
48
+ `It resets ${new Date(state.resetsAt).toUTCString()}. OIOXO Pro is unlimited → ${UPGRADE_URL}`,
49
+ };
50
+ }
51
+ return { ok: true, state };
52
+ }
53
+ export async function startServer(root) {
54
+ const server = new McpServer({ name: 'oioxo', version: VERSION });
55
+ // ---- on-device index (lazy build, time-based refresh) ----
56
+ let files = [];
57
+ const index = new Bm25Index();
58
+ let indexedAt = 0;
59
+ let indexing = null;
60
+ const ensureIndex = async () => {
61
+ if (Date.now() - indexedAt < REINDEX_MS && files.length)
62
+ return;
63
+ if (!indexing) {
64
+ indexing = (async () => {
65
+ files = await loadProject(root);
66
+ index.build(files);
67
+ indexedAt = Date.now();
68
+ })().finally(() => { indexing = null; });
69
+ }
70
+ return indexing;
71
+ };
72
+ server.tool('get_context', 'ALWAYS call this FIRST, before reading or searching files yourself. Returns the minimal relevant slice of this codebase for a task: the exact code in play plus the API surface of its real dependencies — typically 10-20x fewer tokens than reading the files. Powered by OIOXO, 100% on-device.', { query: z.string().describe('The task or question, in natural language (include identifiers/error text when you have them).') }, async ({ query }) => {
73
+ const gate = await checkGate();
74
+ if (!gate.ok)
75
+ return text(gate.message);
76
+ await ensureIndex();
77
+ const capsule = buildCapsule(query, files, index);
78
+ if (!capsule.text)
79
+ return text('No relevant code found for that query in this workspace.');
80
+ // Report the savings (server clamps; Pro stays unlimited — the report only feeds stats).
81
+ if (capsule.savedTokens > 0)
82
+ void saveMeter('report', capsule.savedTokens);
83
+ const ctx = contextBlock(await readProjectContext(root));
84
+ const header = `OIOXO context capsule — files: ${capsule.files.join(', ')} (≈${capsule.savedTokens.toLocaleString()} tokens saved vs reading full files)`;
85
+ return text([header, ctx, capsule.text].filter(Boolean).join('\n\n'));
86
+ });
87
+ server.tool('get_skeleton', 'The API surface of one file — signatures, no implementation bodies. Use to learn how to CALL code without paying for its full text.', { path: z.string().describe('Workspace-relative file path.') }, async ({ path: rel }) => {
88
+ const gate = await checkGate();
89
+ if (!gate.ok)
90
+ return text(gate.message);
91
+ await ensureIndex();
92
+ const f = files.find((x) => x.path === rel.replace(/\\/g, '/'));
93
+ if (!f)
94
+ return text(`File not found in the OIOXO index: ${rel}`);
95
+ const skel = fileSkeleton(f.path, f.content);
96
+ const saved = Math.max(0, Math.round((f.content.length - skel.length) / 4));
97
+ if (saved > 0)
98
+ void saveMeter('report', saved);
99
+ return text(skel);
100
+ });
101
+ server.tool('get_impact', 'Blast radius of a file: what it imports and everything that imports IT. Call before refactoring or deleting to know what may break.', { path: z.string().describe('Workspace-relative file path.') }, async ({ path: rel }) => {
102
+ const gate = await checkGate();
103
+ if (!gate.ok)
104
+ return text(gate.message);
105
+ await ensureIndex();
106
+ const p = rel.replace(/\\/g, '/');
107
+ const graph = buildGraph(files);
108
+ const deps = graph.deps.get(p) ?? [];
109
+ const dependents = graph.dependents.get(p) ?? [];
110
+ return text(`OIOXO impact graph for ${p}\n\nIMPORTS (its dependencies):\n${deps.map((d) => ` ${d}`).join('\n') || ' (none in-project)'}\n\nIMPORTED BY (changing ${p} may break these):\n${dependents.map((d) => ` ${d}`).join('\n') || ' (none — no in-project callers)'}`);
111
+ });
112
+ server.tool('remember', 'Save one durable fact about THIS project to .oioxo/memory.md (on-device, shared with the OIOXO IDEs). Use for decisions, conventions, gotchas worth keeping.', { fact: z.string().describe('The fact, one concise sentence.') }, async ({ fact }) => {
113
+ await rememberFact(root, fact);
114
+ return text('Saved to .oioxo/memory.md.');
115
+ });
116
+ server.tool('recall', "This project's rules and learned memory from .oioxo/ (same files the OIOXO IDEs maintain). Call when starting work in an unfamiliar repo.", {}, async () => {
117
+ const block = contextBlock(await readProjectContext(root));
118
+ return text(block || 'No .oioxo/rules.md or memory.md in this project yet.');
119
+ });
120
+ const transport = new StdioServerTransport();
121
+ await server.connect(transport);
122
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Unit tests for the OIOXO context engine core. Run via `npm test`
3
+ * (tsc build → node --test on the compiled output).
4
+ */
5
+ import { test } from 'node:test';
6
+ import * as assert from 'node:assert/strict';
7
+ import { promises as fs } from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import * as os from 'node:os';
10
+ import { Bm25Index, tokenize } from '../core/bm25.js';
11
+ import { importSpecifiers, resolveImport, buildGraph, expandContext } from '../core/code-graph.js';
12
+ import { extractSignatures, fileSkeleton } from '../core/skeleton.js';
13
+ import { buildCapsule } from '../core/capsule.js';
14
+ import { rememberFact, readProjectContext, contextBlock } from '../core/memory.js';
15
+ import { loadProject } from '../core/files.js';
16
+ // ---------- tokenizer ----------
17
+ test('tokenize splits camelCase and snake_case identifiers', () => {
18
+ const toks = tokenize('getUserAuthToken par_ser');
19
+ assert.ok(toks.includes('user'));
20
+ assert.ok(toks.includes('auth'));
21
+ assert.ok(toks.includes('par'));
22
+ assert.ok(toks.includes('ser'));
23
+ });
24
+ // ---------- BM25 ----------
25
+ const FILES = [
26
+ { path: 'src/auth/login.ts', content: 'export function loginUser(email: string, password: string) {\n return checkPassword(email, password);\n}\nfunction checkPassword(e: string, p: string) { return e && p; }\n' },
27
+ { path: 'src/billing/invoice.ts', content: 'import { loginUser } from "../auth/login";\nexport function createInvoice(total: number) {\n loginUser("a", "b");\n return { total };\n}\n' },
28
+ { path: 'src/util/math.ts', content: 'export const add = (a: number, b: number) => a + b;\n' },
29
+ ];
30
+ test('BM25 retrieves the chunk about the query topic', () => {
31
+ const idx = new Bm25Index();
32
+ const st = idx.build(FILES);
33
+ assert.equal(st.files, 3);
34
+ assert.ok(st.chunks >= 3);
35
+ const hits = idx.retrieve('login password check', 3);
36
+ assert.ok(hits.length > 0);
37
+ assert.equal(hits[0].path, 'src/auth/login.ts');
38
+ });
39
+ // ---------- code graph ----------
40
+ test('importSpecifiers finds all import forms', () => {
41
+ const specs = importSpecifiers(`import a from './a';\nimport './b';\nconst c = require('./c');\nexport { d } from './d';\nconst e = import('./e');`);
42
+ assert.deepEqual(specs.sort(), ['./a', './b', './c', './d', './e']);
43
+ });
44
+ test('resolveImport tries extensions and /index', () => {
45
+ const set = new Set(['src/a.ts', 'src/lib/index.ts']);
46
+ assert.equal(resolveImport('./a', 'src/main.ts', set), 'src/a.ts');
47
+ assert.equal(resolveImport('./lib', 'src/main.ts', set), 'src/lib/index.ts');
48
+ assert.equal(resolveImport('react', 'src/main.ts', set), null);
49
+ });
50
+ test('buildGraph + expandContext reach real dependencies', () => {
51
+ const graph = buildGraph(FILES);
52
+ assert.deepEqual(graph.deps.get('src/billing/invoice.ts'), ['src/auth/login.ts']);
53
+ assert.deepEqual(graph.dependents.get('src/auth/login.ts'), ['src/billing/invoice.ts']);
54
+ const slice = expandContext(graph, ['src/billing/invoice.ts'], { hops: 2, max: 5 });
55
+ assert.ok(slice.includes('src/auth/login.ts'));
56
+ assert.ok(!slice.includes('src/util/math.ts')); // unconnected decoy stays out
57
+ });
58
+ // ---------- skeleton ----------
59
+ test('extractSignatures gets TS functions/classes without bodies', () => {
60
+ const sigs = extractSignatures('a.ts', 'export async function fetchUser(id: string): Promise<User> {\n return db.get(id);\n}\nexport class UserStore {\n private x = 1;\n}\nexport interface User { id: string }\n');
61
+ const names = sigs.map((s) => s.name);
62
+ assert.ok(names.includes('fetchUser'));
63
+ assert.ok(names.includes('UserStore'));
64
+ assert.ok(names.includes('User'));
65
+ const fn = sigs.find((s) => s.name === 'fetchUser');
66
+ assert.ok(!fn.signature.includes('db.get'));
67
+ });
68
+ test('extractSignatures handles python', () => {
69
+ const sigs = extractSignatures('a.py', 'class Parser:\n pass\n\ndef parse_file(path):\n return path\n');
70
+ assert.deepEqual(sigs.map((s) => s.name).sort(), ['Parser', 'parse_file']);
71
+ });
72
+ test('fileSkeleton is much smaller than the file', () => {
73
+ const big = 'export function f1(a: number) {\n' + ' // body\n'.repeat(200) + '}\n';
74
+ const skel = fileSkeleton('big.ts', big);
75
+ assert.ok(skel.length < big.length / 5);
76
+ });
77
+ // ---------- capsule ----------
78
+ test('buildCapsule ships anchors full + neighbors as skeletons + counts savings', () => {
79
+ const idx = new Bm25Index();
80
+ idx.build(FILES);
81
+ const cap = buildCapsule('create invoice total', FILES, idx);
82
+ assert.ok(cap.files.includes('src/billing/invoice.ts'));
83
+ assert.ok(cap.text.includes('createInvoice'));
84
+ assert.ok(cap.savedTokens >= 0);
85
+ assert.ok(cap.text.length > 0);
86
+ });
87
+ // ---------- memory ----------
88
+ test('rememberFact dedups and bounds; contextBlock formats', async () => {
89
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oioxo-mem-'));
90
+ await rememberFact(root, 'uses pnpm not npm');
91
+ await rememberFact(root, 'uses pnpm not npm'); // dup
92
+ await rememberFact(root, 'API lives under /api/v2');
93
+ const ctx = await readProjectContext(root);
94
+ const bullets = ctx.memory.split('\n').filter((l) => l.startsWith('- '));
95
+ assert.equal(bullets.length, 2);
96
+ assert.ok(contextBlock(ctx).includes('PROJECT MEMORY'));
97
+ await fs.rm(root, { recursive: true, force: true });
98
+ });
99
+ // ---------- file walking ----------
100
+ test('loadProject respects excludes and extensions', async () => {
101
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oioxo-files-'));
102
+ await fs.mkdir(path.join(root, 'node_modules', 'x'), { recursive: true });
103
+ await fs.writeFile(path.join(root, 'node_modules', 'x', 'index.js'), 'ignored');
104
+ await fs.writeFile(path.join(root, 'app.ts'), 'export const a = 1;');
105
+ await fs.writeFile(path.join(root, 'photo.png'), 'binaryish');
106
+ const files = await loadProject(root);
107
+ assert.deepEqual(files.map((f) => f.path), ['app.ts']);
108
+ await fs.rm(root, { recursive: true, force: true });
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ /**
2
+ * End-to-end smoke test: spawn the real MCP server over stdio (exactly how
3
+ * Claude Code / Copilot / Cursor will) with the official SDK client, then:
4
+ * - list tools (all 5 present),
5
+ * - call the UNGATED memory tools (remember → recall round-trip),
6
+ * - call the GATED get_context with no credential → expect the OIOXO
7
+ * sign-in gate message, NOT code (monetization is the product rule).
8
+ *
9
+ * Hermetic: OIOXO_HOME points at a temp dir (no credential), and the control
10
+ * plane points at a dead port so even a mis-read credential can't pass the gate.
11
+ */
12
+ import { test } from 'node:test';
13
+ import * as assert from 'node:assert/strict';
14
+ import { promises as fs } from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import * as os from 'node:os';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
19
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const CLI = path.resolve(__dirname, '..', 'cli', 'index.js');
22
+ const textOf = (r) => {
23
+ const c = r.content ?? [];
24
+ return c.filter((x) => x.type === 'text').map((x) => x.text ?? '').join('\n');
25
+ };
26
+ test('MCP server end-to-end over stdio', async () => {
27
+ // Fixture workspace the server will index.
28
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oioxo-e2e-'));
29
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'oioxo-home-'));
30
+ await fs.writeFile(path.join(root, 'cart.ts'), 'import { tax } from "./tax";\nexport function cartTotal(items: number[]) {\n return items.reduce((a, b) => a + b, 0) * tax();\n}\n');
31
+ await fs.writeFile(path.join(root, 'tax.ts'), 'export function tax() { return 1.13; }\n');
32
+ const transport = new StdioClientTransport({
33
+ command: process.execPath,
34
+ args: [CLI, 'serve'],
35
+ cwd: root,
36
+ env: {
37
+ ...process.env,
38
+ OIOXO_HOME: home,
39
+ OIOXO_CONTROL_PLANE: 'http://127.0.0.1:9', // dead — gate must hold offline
40
+ },
41
+ });
42
+ const client = new Client({ name: 'oioxo-e2e', version: '0.0.1' });
43
+ await client.connect(transport);
44
+ try {
45
+ // 1) Tool inventory.
46
+ const tools = await client.listTools();
47
+ const names = tools.tools.map((t) => t.name).sort();
48
+ assert.deepEqual(names, ['get_context', 'get_impact', 'get_skeleton', 'recall', 'remember']);
49
+ // 2) Ungated memory round-trip.
50
+ await client.callTool({ name: 'remember', arguments: { fact: 'tax rate is 13 percent' } });
51
+ const recall = textOf(await client.callTool({ name: 'recall', arguments: {} }));
52
+ assert.ok(recall.includes('tax rate is 13 percent'));
53
+ const memOnDisk = await fs.readFile(path.join(root, '.oioxo', 'memory.md'), 'utf8');
54
+ assert.ok(memOnDisk.includes('- tax rate is 13 percent'));
55
+ // 3) Gated retrieval without a credential → OIOXO gate message, no code.
56
+ const gated = textOf(await client.callTool({ name: 'get_context', arguments: { query: 'cart total tax' } }));
57
+ assert.ok(gated.includes('oioxo-mcp login'), `expected login gate, got: ${gated.slice(0, 200)}`);
58
+ assert.ok(!gated.includes('cartTotal'), 'gated call must not leak retrieval');
59
+ }
60
+ finally {
61
+ await client.close().catch(() => { });
62
+ await fs.rm(root, { recursive: true, force: true });
63
+ await fs.rm(home, { recursive: true, force: true });
64
+ }
65
+ });
66
+ test('init writes + merges agent configs (project + global)', async () => {
67
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oioxo-init-'));
68
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'oioxo-inithome-'));
69
+ // Pre-existing VS Code MCP config that must survive the merge.
70
+ await fs.mkdir(path.join(root, '.vscode'), { recursive: true });
71
+ await fs.writeFile(path.join(root, '.vscode', 'mcp.json'), JSON.stringify({ servers: { other: { command: 'x' } } }));
72
+ // "Installed" Windsurf + Gemini + Codex on the fake machine.
73
+ await fs.mkdir(path.join(home, '.codeium', 'windsurf'), { recursive: true });
74
+ await fs.mkdir(path.join(home, '.gemini'), { recursive: true });
75
+ await fs.mkdir(path.join(home, '.codex'), { recursive: true });
76
+ await fs.writeFile(path.join(home, '.codex', 'config.toml'), 'model = "o4"\n');
77
+ const { initAgents } = await import('../cli/agents.js');
78
+ await initAgents(root, { all: true, command: 'oioxo-mcp', home });
79
+ const vsc = JSON.parse(await fs.readFile(path.join(root, '.vscode', 'mcp.json'), 'utf8'));
80
+ assert.ok(vsc.servers.other, 'existing server entry preserved');
81
+ assert.equal(vsc.servers.oioxo.command, 'oioxo-mcp');
82
+ const claude = JSON.parse(await fs.readFile(path.join(root, '.mcp.json'), 'utf8'));
83
+ assert.equal(claude.mcpServers.oioxo.command, 'oioxo-mcp');
84
+ const cursor = JSON.parse(await fs.readFile(path.join(root, '.cursor', 'mcp.json'), 'utf8'));
85
+ assert.equal(cursor.mcpServers.oioxo.command, 'oioxo-mcp');
86
+ // Globals: written because detected; TOML appended without clobbering.
87
+ const windsurf = JSON.parse(await fs.readFile(path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), 'utf8'));
88
+ assert.equal(windsurf.mcpServers.oioxo.command, 'oioxo-mcp');
89
+ const gemini = JSON.parse(await fs.readFile(path.join(home, '.gemini', 'settings.json'), 'utf8'));
90
+ assert.equal(gemini.mcpServers.oioxo.command, 'oioxo-mcp');
91
+ const codex = await fs.readFile(path.join(home, '.codex', 'config.toml'), 'utf8');
92
+ assert.ok(codex.startsWith('model = "o4"'), 'existing TOML preserved');
93
+ assert.ok(codex.includes('[mcp_servers.oioxo]'));
94
+ // Idempotent: second run must not duplicate the TOML section.
95
+ await initAgents(root, { all: true, command: 'oioxo-mcp', home });
96
+ const codex2 = await fs.readFile(path.join(home, '.codex', 'config.toml'), 'utf8');
97
+ assert.equal(codex2.match(/\[mcp_servers\.oioxo\]/g)?.length, 1);
98
+ await fs.rm(root, { recursive: true, force: true });
99
+ await fs.rm(home, { recursive: true, force: true });
100
+ });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "oioxo-mcp",
3
+ "version": "0.1.0",
4
+ "description": "OIOXO context engine for AI coding agents — feeds Claude Code, Copilot, Cursor and any MCP-capable agent the minimal relevant slice of your codebase, on-device, so your tokens go further.",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "type": "module",
7
+ "bin": {
8
+ "oioxo-mcp": "dist/cli/index.js"
9
+ },
10
+ "main": "dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.17"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/aradsoftca/oioxo-mcp.git"
21
+ },
22
+ "scripts": {
23
+ "prepublishOnly": "npm run build",
24
+ "build": "tsc -p tsconfig.json",
25
+ "test": "npm run build && node --test dist/test/core.test.js dist/test/e2e.test.js",
26
+ "serve": "node dist/cli/index.js serve"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "model-context-protocol",
31
+ "context-engine",
32
+ "claude-code",
33
+ "copilot",
34
+ "cursor",
35
+ "oioxo",
36
+ "token-saver"
37
+ ],
38
+ "homepage": "https://oioxo.com",
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.12.0",
41
+ "zod": "^3.23.8"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^20.14.0",
45
+ "typescript": "^5.5.0"
46
+ }
47
+ }