@mayamemo/mcp 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MayaMemo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @mayamemo/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MayaMemo MCP server — durable project memory, checkpoints and handoffs for coding agents, over stdio.
4
+
5
+ ## Use with opencode (zero install)
6
+
7
+ ```json
8
+ {
9
+ "mcp": {
10
+ "mayamemo": {
11
+ "type": "local",
12
+ "command": ["npx", "-y", "@mayamemo/mcp"],
13
+ "environment": {
14
+ "MAYAMEMO_API_URL": "https://api.mayamemo.com",
15
+ "MAYAMEMO_TOKEN": "mm_sk_..."
16
+ },
17
+ "enabled": true
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ Any MCP-capable host works the same way: `npx -y @mayamemo/mcp` with
24
+ `MAYAMEMO_API_URL` + `MAYAMEMO_TOKEN` in the environment.
25
+
26
+ Get a key at https://mayamemo.com → Account settings → Developers.
27
+
28
+ Tools: `memory_save`, `memory_search`, `context_search`, `context_get`,
29
+ `project_state`, `checkpoint_create`, `checkpoint_resume`, `entity_observe`,
30
+ `graph_query`, `attachment_upload`, `attachment_describe`, `memory_feedback`,
31
+ `session_start`, `session_end`.
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { createHttpCallApi, createStdioRunner } from './stdio.js';
3
+ const apiUrl = process.env.MAYAMEMO_API_URL ?? 'http://localhost:4000';
4
+ const token = process.env.MAYAMEMO_TOKEN;
5
+ if (!token) {
6
+ process.stderr.write('mayamemo-mcp: MAYAMEMO_TOKEN is required (workspace API key, mm_sk_...).\n');
7
+ process.exit(1);
8
+ }
9
+ process.stderr.write(`mayamemo-mcp: serving on stdio, API at ${apiUrl}\n`);
10
+ createStdioRunner({
11
+ input: process.stdin,
12
+ output: process.stdout,
13
+ deps: { callApi: createHttpCallApi(apiUrl, token) },
14
+ onError: (message) => process.stderr.write(`${message}\n`),
15
+ });
@@ -0,0 +1,3 @@
1
+ export * from './server.js';
2
+ export * from './tools.js';
3
+ export * from './stdio.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './server.js';
2
+ export * from './tools.js';
3
+ export * from './stdio.js';
@@ -0,0 +1,25 @@
1
+ import type { McpDeps, McpToolDefinition } from './tools.js';
2
+ /**
3
+ * MCP protocol layer (JSON-RPC 2.0 over newline-delimited stdio).
4
+ * ADR-003: this package is an ADAPTER — it contains no domain logic; every tool
5
+ * delegates to the MayaMemo HTTP API through the injected `callApi` transport.
6
+ */
7
+ export interface McpServerInfo {
8
+ name: string;
9
+ version: string;
10
+ }
11
+ export declare const MCP_SERVER_INFO: McpServerInfo;
12
+ export declare const MCP_PROTOCOL_FALLBACK = "2025-06-18";
13
+ export declare const SUPPORTED_PROTOCOL_VERSIONS: readonly ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"];
14
+ export interface JsonRpcRequest {
15
+ jsonrpc: '2.0';
16
+ id?: number | string;
17
+ method: string;
18
+ params?: Record<string, unknown>;
19
+ }
20
+ export interface McpServer {
21
+ /** Handles one raw JSON-RPC line. Returns the serialized response, or null for notifications. */
22
+ handleMessage(line: string): Promise<string | null>;
23
+ tools: readonly McpToolDefinition[];
24
+ }
25
+ export declare function createMcpServer(deps: McpDeps): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,110 @@
1
+ import { findTool, ToolInputError, TOOLS } from './tools.js';
2
+ export const MCP_SERVER_INFO = { name: 'mayamemo-mcp', version: '0.1.0' };
3
+ export const MCP_PROTOCOL_FALLBACK = '2025-06-18';
4
+ export const SUPPORTED_PROTOCOL_VERSIONS = [
5
+ '2024-11-05',
6
+ '2025-03-26',
7
+ '2025-06-18',
8
+ '2025-11-25',
9
+ '2026-07-28',
10
+ ];
11
+ const PARSE_ERROR = -32700;
12
+ const METHOD_NOT_FOUND = -32601;
13
+ const INVALID_PARAMS = -32602;
14
+ const INTERNAL_ERROR = -32603;
15
+ function rpcSuccess(id, result) {
16
+ return { jsonrpc: '2.0', id, result };
17
+ }
18
+ function rpcError(id, code, message) {
19
+ return { jsonrpc: '2.0', id, error: { code, message } };
20
+ }
21
+ export function createMcpServer(deps) {
22
+ async function dispatch(request) {
23
+ const { method } = request;
24
+ const id = request.id;
25
+ // Notifications (no id) never get a response.
26
+ if (method.startsWith('notifications/'))
27
+ return null;
28
+ if (method === 'initialize') {
29
+ const requested = request.params?.protocolVersion ?? MCP_PROTOCOL_FALLBACK;
30
+ const version = SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
31
+ ? requested
32
+ : MCP_PROTOCOL_FALLBACK;
33
+ if (id === undefined)
34
+ return null;
35
+ return rpcSuccess(id, {
36
+ protocolVersion: version,
37
+ capabilities: { tools: {} },
38
+ serverInfo: MCP_SERVER_INFO,
39
+ });
40
+ }
41
+ if (method === 'ping') {
42
+ return id === undefined ? null : rpcSuccess(id, {});
43
+ }
44
+ if (method === 'tools/list') {
45
+ if (id === undefined)
46
+ return null;
47
+ return rpcSuccess(id, {
48
+ tools: TOOLS.map((tool) => ({
49
+ name: tool.name,
50
+ description: tool.description,
51
+ inputSchema: tool.inputSchema,
52
+ })),
53
+ });
54
+ }
55
+ if (method === 'tools/call') {
56
+ if (id === undefined)
57
+ return null;
58
+ const name = request.params?.name;
59
+ const args = request.params?.arguments ?? {};
60
+ if (!name) {
61
+ return rpcError(id, INVALID_PARAMS, 'tools/call requires params.name');
62
+ }
63
+ const tool = findTool(name);
64
+ if (!tool) {
65
+ return rpcError(id, INVALID_PARAMS, `Unknown tool: ${name}. Available tools: ${TOOLS.map((t) => t.name).join(', ')}.`);
66
+ }
67
+ try {
68
+ const result = await tool.handler(args, deps);
69
+ return rpcSuccess(id, {
70
+ content: [{ type: 'text', text: result.text }],
71
+ isError: result.isError ?? false,
72
+ });
73
+ }
74
+ catch (error) {
75
+ if (error instanceof ToolInputError) {
76
+ return rpcError(id, INVALID_PARAMS, error.message);
77
+ }
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ return rpcSuccess(id, {
80
+ content: [{ type: 'text', text: `Tool execution failed: ${message}` }],
81
+ isError: true,
82
+ });
83
+ }
84
+ }
85
+ return id === undefined ? null : rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`);
86
+ }
87
+ return {
88
+ tools: TOOLS,
89
+ async handleMessage(line) {
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(line);
93
+ }
94
+ catch {
95
+ return JSON.stringify(rpcError(null, PARSE_ERROR, 'Parse error: invalid JSON'));
96
+ }
97
+ if (parsed.jsonrpc !== '2.0' || typeof parsed.method !== 'string') {
98
+ return JSON.stringify(rpcError(parsed.id ?? null, INVALID_PARAMS, 'Invalid JSON-RPC 2.0 request'));
99
+ }
100
+ try {
101
+ const response = await dispatch(parsed);
102
+ return response === null ? null : JSON.stringify(response);
103
+ }
104
+ catch (error) {
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ return JSON.stringify(rpcError(parsed.id ?? null, INTERNAL_ERROR, message));
107
+ }
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,15 @@
1
+ import type { McpDeps } from './tools.js';
2
+ export interface StdioRunnerOptions {
3
+ input: NodeJS.ReadableStream;
4
+ output: NodeJS.WritableStream;
5
+ deps: McpDeps;
6
+ onError?: (message: string) => void;
7
+ }
8
+ /**
9
+ * Newline-delimited JSON-RPC stdio loop. stdout carries ONLY protocol messages;
10
+ * diagnostics go to the provided error sink (stderr in production).
11
+ */
12
+ export declare function createStdioRunner(options: StdioRunnerOptions): {
13
+ stop(): void;
14
+ };
15
+ export declare function createHttpCallApi(apiUrl: string, token: string): McpDeps['callApi'];
package/dist/stdio.js ADDED
@@ -0,0 +1,72 @@
1
+ import { createMcpServer } from './server.js';
2
+ /**
3
+ * Newline-delimited JSON-RPC stdio loop. stdout carries ONLY protocol messages;
4
+ * diagnostics go to the provided error sink (stderr in production).
5
+ */
6
+ export function createStdioRunner(options) {
7
+ const server = createMcpServer(options.deps);
8
+ const logError = options.onError ?? ((message) => process.stderr.write(`${message}\n`));
9
+ let buffer = '';
10
+ let queue = Promise.resolve();
11
+ const handleLine = (line) => {
12
+ const trimmed = line.trim();
13
+ if (trimmed.length === 0)
14
+ return;
15
+ queue = queue
16
+ .then(async () => {
17
+ const response = await server.handleMessage(trimmed);
18
+ if (response !== null) {
19
+ options.output.write(`${response}\n`);
20
+ }
21
+ })
22
+ .catch((error) => {
23
+ logError(`mcp: internal loop error: ${error instanceof Error ? error.message : String(error)}`);
24
+ });
25
+ };
26
+ const onData = (chunk) => {
27
+ buffer += chunk.toString();
28
+ let newlineIndex = buffer.indexOf('\n');
29
+ while (newlineIndex >= 0) {
30
+ const line = buffer.slice(0, newlineIndex);
31
+ buffer = buffer.slice(newlineIndex + 1);
32
+ handleLine(line);
33
+ newlineIndex = buffer.indexOf('\n');
34
+ }
35
+ };
36
+ const onEnd = () => {
37
+ if (buffer.trim().length > 0) {
38
+ handleLine(buffer);
39
+ buffer = '';
40
+ }
41
+ };
42
+ options.input.on('data', onData);
43
+ options.input.on('end', onEnd);
44
+ return {
45
+ stop() {
46
+ options.input.off('data', onData);
47
+ options.input.off('end', onEnd);
48
+ },
49
+ };
50
+ }
51
+ export function createHttpCallApi(apiUrl, token) {
52
+ const base = apiUrl.replace(/\/+$/, '');
53
+ return async (method, path, body) => {
54
+ const res = await fetch(`${base}${path}`, {
55
+ method,
56
+ headers: {
57
+ authorization: `Bearer ${token}`,
58
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
59
+ },
60
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
61
+ });
62
+ const text = await res.text();
63
+ let parsed = null;
64
+ try {
65
+ parsed = text.length > 0 ? JSON.parse(text) : null;
66
+ }
67
+ catch {
68
+ parsed = { raw: text.slice(0, 500) };
69
+ }
70
+ return { status: res.status, body: parsed };
71
+ };
72
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * MCP tool surface (adapter layer). Handlers validate inputs, delegate to the
3
+ * MayaMemo HTTP API via `deps.callApi`, and bound their outputs.
4
+ * V1 tools per docs/MCP.md: context_get, context_search, memory_search,
5
+ * memory_save, project_state, checkpoint_create (unlocked at M06).
6
+ */
7
+ /** Thrown by handlers for invalid tool arguments → JSON-RPC -32602. */
8
+ export declare class ToolInputError extends Error {
9
+ }
10
+ export interface McpDeps {
11
+ callApi: (method: 'GET' | 'POST', path: string, body?: unknown) => Promise<{
12
+ status: number;
13
+ body: unknown;
14
+ }>;
15
+ }
16
+ export interface McpToolResult {
17
+ text: string;
18
+ isError?: boolean;
19
+ }
20
+ export interface McpToolDefinition {
21
+ name: string;
22
+ description: string;
23
+ inputSchema: Record<string, unknown>;
24
+ handler: (args: Record<string, unknown>, deps: McpDeps) => Promise<McpToolResult>;
25
+ }
26
+ export declare const CHECKPOINT_TOOL_READY = true;
27
+ export declare const MAX_CONTEXT_ITEMS = 30;
28
+ export declare const MAX_SEARCH_RESULTS = 25;
29
+ export declare const MAX_ITEM_CHARS = 400;
30
+ export declare const TOOLS: McpToolDefinition[];
31
+ export declare function findTool(name: string): McpToolDefinition | undefined;
32
+ export declare const MCP_TOOL_NAMES: string[];
package/dist/tools.js ADDED
@@ -0,0 +1,622 @@
1
+ /**
2
+ * MCP tool surface (adapter layer). Handlers validate inputs, delegate to the
3
+ * MayaMemo HTTP API via `deps.callApi`, and bound their outputs.
4
+ * V1 tools per docs/MCP.md: context_get, context_search, memory_search,
5
+ * memory_save, project_state, checkpoint_create (unlocked at M06).
6
+ */
7
+ /** Thrown by handlers for invalid tool arguments → JSON-RPC -32602. */
8
+ export class ToolInputError extends Error {
9
+ }
10
+ export const CHECKPOINT_TOOL_READY = true;
11
+ export const MAX_CONTEXT_ITEMS = 30;
12
+ export const MAX_SEARCH_RESULTS = 25;
13
+ export const MAX_ITEM_CHARS = 400;
14
+ function requireString(args, key) {
15
+ const value = args[key];
16
+ if (typeof value !== 'string' || value.trim().length === 0) {
17
+ throw new ToolInputError(`Missing or invalid required argument: ${key}`);
18
+ }
19
+ return value;
20
+ }
21
+ function optionalString(args, key) {
22
+ const value = args[key];
23
+ if (value === undefined || value === null)
24
+ return undefined;
25
+ if (typeof value !== 'string')
26
+ throw new ToolInputError(`Argument ${key} must be a string`);
27
+ return value;
28
+ }
29
+ function optionalNumber(args, key) {
30
+ const value = args[key];
31
+ if (value === undefined || value === null)
32
+ return undefined;
33
+ if (typeof value !== 'number' || Number.isNaN(value)) {
34
+ throw new ToolInputError(`Argument ${key} must be a number`);
35
+ }
36
+ return value;
37
+ }
38
+ function optionalStringArray(args, key, cap) {
39
+ const value = args[key];
40
+ if (value === undefined || value === null)
41
+ return undefined;
42
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
43
+ throw new ToolInputError(`Argument ${key} must be an array of strings`);
44
+ }
45
+ return value.slice(0, cap);
46
+ }
47
+ function entityRefs(args, key, cap) {
48
+ const value = args[key];
49
+ if (value === undefined || value === null)
50
+ return undefined;
51
+ if (!Array.isArray(value))
52
+ throw new ToolInputError(`Argument ${key} must be an array`);
53
+ const refs = [];
54
+ for (const item of value.slice(0, cap)) {
55
+ const record = item;
56
+ if (typeof record?.kind !== 'string' || typeof record?.name !== 'string') {
57
+ throw new ToolInputError(`Argument ${key} entries need string "kind" and "name"`);
58
+ }
59
+ refs.push({ kind: record.kind, name: record.name });
60
+ }
61
+ return refs;
62
+ }
63
+ function apiErrorText(status, body) {
64
+ const error = body.error;
65
+ const code = error?.code ?? 'API_ERROR';
66
+ const message = error?.message ?? `HTTP ${status}`;
67
+ return { text: `MayaMemo API error (${status}) [${code}]: ${message}`, isError: true };
68
+ }
69
+ function bounded(text, max = MAX_ITEM_CHARS) {
70
+ return text.length > max ? `${text.slice(0, max)}…` : text;
71
+ }
72
+ export const TOOLS = [
73
+ {
74
+ name: 'memory_save',
75
+ description: 'Save a durable project memory (decision, constraint, bug, lesson, fact, preference, task, discovery, incident, architecture, requirement) to the MayaMemo ledger. Long statements are chunked server-side on sentence boundaries — nothing is dropped.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ project_id: { type: 'string', description: 'MayaMemo project id (from the project mapping)' },
80
+ statement: { type: 'string', description: 'The durable claim, present tense' },
81
+ type: { type: 'string', description: 'Memory type' },
82
+ confidence: { type: 'number', description: '0..1 how strongly the evidence supports it' },
83
+ importance: { type: 'number', description: '0..1 how valuable for future sessions' },
84
+ source_session_id: { type: 'string', description: 'Optional source session id' },
85
+ source_event_id: { type: 'string', description: 'Optional source event id' },
86
+ tags: {
87
+ type: 'array',
88
+ items: { type: 'string' },
89
+ description: 'Optional tags for organization/filtering (lowercased, max 10, alphanumeric)',
90
+ },
91
+ entities: {
92
+ type: 'array',
93
+ items: {
94
+ type: 'object',
95
+ properties: {
96
+ kind: { type: 'string', description: 'file, module, symbol, endpoint, table, concept…' },
97
+ name: { type: 'string' },
98
+ },
99
+ required: ['kind', 'name'],
100
+ },
101
+ description: 'Optional code entities this memory is about (linked into the project graph)',
102
+ },
103
+ },
104
+ required: ['project_id', 'statement', 'type'],
105
+ },
106
+ async handler(args, deps) {
107
+ const payload = {
108
+ project_id: requireString(args, 'project_id'),
109
+ statement: requireString(args, 'statement'),
110
+ type: requireString(args, 'type'),
111
+ };
112
+ const confidence = optionalNumber(args, 'confidence');
113
+ if (confidence !== undefined)
114
+ payload.confidence = confidence;
115
+ const importance = optionalNumber(args, 'importance');
116
+ if (importance !== undefined)
117
+ payload.importance = importance;
118
+ const sourceSession = optionalString(args, 'source_session_id');
119
+ if (sourceSession !== undefined)
120
+ payload.source_session_id = sourceSession;
121
+ const sourceEvent = optionalString(args, 'source_event_id');
122
+ if (sourceEvent !== undefined)
123
+ payload.source_event_id = sourceEvent;
124
+ if (Array.isArray(args.tags) && args.tags.length > 0)
125
+ payload.tags = args.tags.map(String);
126
+ const memoryEntities = entityRefs(args, 'entities', 20);
127
+ if (memoryEntities)
128
+ payload.entities = memoryEntities;
129
+ const res = await deps.callApi('POST', '/v1/memories', payload);
130
+ if (res.status >= 400)
131
+ return apiErrorText(res.status, res.body);
132
+ const memory = res.body;
133
+ return {
134
+ text: `Saved memory ${memory.id} [${memory.type}/${memory.status}]: ${bounded(memory.statement)}`,
135
+ };
136
+ },
137
+ },
138
+ {
139
+ name: 'memory_search',
140
+ description: 'Hybrid (semantic + keyword) search over the project memory ledger. Supports temporal queries natively — "what changed last week", "since 2026-09-01" — parsed deterministically (no LLM). Returns ranked memories with provenance.',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ project_id: { type: 'string' },
145
+ query: { type: 'string', description: 'Natural-language search query (may embed a time phrase: "last week", "past 3 days", "since 2026-09-01")' },
146
+ status: { type: 'string', description: 'Optional memory status filter (default active)' },
147
+ limit: { type: 'number', description: 'Max results (default 8, max 25)' },
148
+ tags: {
149
+ type: 'array',
150
+ items: { type: 'string' },
151
+ description: 'Optional tag filter — only memories carrying ALL given tags are returned',
152
+ },
153
+ since: { type: 'string', description: 'Optional ISO datetime lower bound (memories created or changed after it)' },
154
+ until: { type: 'string', description: 'Optional ISO datetime upper bound' },
155
+ },
156
+ required: ['project_id', 'query'],
157
+ },
158
+ async handler(args, deps) {
159
+ const payload = {
160
+ project_id: requireString(args, 'project_id'),
161
+ query: requireString(args, 'query'),
162
+ };
163
+ const status = optionalString(args, 'status');
164
+ if (status !== undefined)
165
+ payload.status = status;
166
+ const limit = optionalNumber(args, 'limit');
167
+ payload.limit = Math.min(Math.max(limit ?? 8, 1), MAX_SEARCH_RESULTS);
168
+ if (Array.isArray(args.tags) && args.tags.length > 0)
169
+ payload.tags = args.tags.map(String);
170
+ // Q13: explicit temporal bounds pass through; NL phrases ("last week")
171
+ // are parsed server-side from the query text automatically.
172
+ const since = optionalString(args, 'since');
173
+ if (since !== undefined)
174
+ payload.since = since;
175
+ const until = optionalString(args, 'until');
176
+ if (until !== undefined)
177
+ payload.until = until;
178
+ const res = await deps.callApi('POST', '/v1/memories/search', payload);
179
+ if (res.status >= 400)
180
+ return apiErrorText(res.status, res.body);
181
+ const rows = res.body;
182
+ if (rows.length === 0)
183
+ return { text: 'No memories matched.' };
184
+ const lines = rows
185
+ .slice(0, MAX_SEARCH_RESULTS)
186
+ .map((r, i) => `${i + 1}. [${r.type}] ${bounded(r.statement)}${r.score !== undefined ? ` (score ${Number(r.score).toFixed(3)})` : ''}`);
187
+ return { text: `${rows.length} memories:\n${lines.join('\n')}` };
188
+ },
189
+ },
190
+ {
191
+ name: 'context_search',
192
+ description: 'Compile the minimum sufficient context for a task from the project ledger, memories, and sessions. Respects a hard token budget and returns explainable provenance.',
193
+ inputSchema: {
194
+ type: 'object',
195
+ properties: {
196
+ project_id: { type: 'string' },
197
+ query: { type: 'string', description: 'Task title / intent to compile context for' },
198
+ token_budget: { type: 'number', description: 'Hard token budget (default 4000, max 50000)' },
199
+ branch: { type: 'string', description: 'Current git branch (branch-matched context ranks higher)' },
200
+ },
201
+ required: ['project_id', 'query'],
202
+ },
203
+ async handler(args, deps) {
204
+ const payload = {
205
+ project_id: requireString(args, 'project_id'),
206
+ title: requireString(args, 'query'),
207
+ token_budget: Math.min(Math.max(optionalNumber(args, 'token_budget') ?? 4000, 64), 50000),
208
+ max_items: MAX_CONTEXT_ITEMS,
209
+ };
210
+ const branch = optionalString(args, 'branch');
211
+ if (branch !== undefined)
212
+ payload.branch = branch;
213
+ const res = await deps.callApi('POST', '/v1/context/compile', payload);
214
+ if (res.status >= 400)
215
+ return apiErrorText(res.status, res.body);
216
+ const body = res.body;
217
+ const lines = body.items
218
+ .slice(0, MAX_CONTEXT_ITEMS)
219
+ .map((item) => {
220
+ const preview = item.reason?.preview ?? item.source_id;
221
+ return `${item.rank}. [${item.source_type}] ${bounded(preview)}`;
222
+ });
223
+ return {
224
+ text: [
225
+ `Context version ${body.version_id} — ${body.token_count}/${body.token_budget} tokens, ${body.items.length} items (${body.excluded_count} excluded).`,
226
+ ...lines,
227
+ ].join('\n'),
228
+ };
229
+ },
230
+ },
231
+ {
232
+ name: 'context_get',
233
+ description: 'Fetch a previously compiled context version by id, with hydrated item content.',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: { version_id: { type: 'string', description: 'Context version id' } },
237
+ required: ['version_id'],
238
+ },
239
+ async handler(args, deps) {
240
+ const versionId = requireString(args, 'version_id');
241
+ const res = await deps.callApi('GET', `/v1/context/${encodeURIComponent(versionId)}`);
242
+ if (res.status >= 400)
243
+ return apiErrorText(res.status, res.body);
244
+ const body = res.body;
245
+ const lines = body.items
246
+ .slice(0, MAX_CONTEXT_ITEMS)
247
+ .map((item) => `${item.rank}. [${item.source_type}] ${bounded(item.content ?? '')}`);
248
+ return {
249
+ text: [
250
+ `Context version ${versionId} — ${body.token_count}/${body.token_budget} tokens.`,
251
+ ...lines,
252
+ ].join('\n'),
253
+ };
254
+ },
255
+ },
256
+ {
257
+ name: 'project_state',
258
+ description: 'Project state snapshot: counts, last session, last checkpoint, and recent ledger activity.',
259
+ inputSchema: {
260
+ type: 'object',
261
+ properties: { project_id: { type: 'string' } },
262
+ required: ['project_id'],
263
+ },
264
+ async handler(args, deps) {
265
+ const projectId = requireString(args, 'project_id');
266
+ const res = await deps.callApi('GET', `/v1/projects/${encodeURIComponent(projectId)}/state`);
267
+ if (res.status >= 400)
268
+ return apiErrorText(res.status, res.body);
269
+ const state = res.body;
270
+ const lines = [
271
+ `Project "${state.project.name}": ${state.memories.active} active memories (${state.memories.superseded} superseded), ${state.events_count} ledger events.`,
272
+ state.last_session
273
+ ? `Last session: ${state.last_session.started_at} on ${state.last_session.branch ?? 'unknown branch'}`
274
+ : 'No sessions yet.',
275
+ state.last_checkpoint
276
+ ? `Last checkpoint ${state.last_checkpoint.id} (${state.last_checkpoint.created_at}) — take over with checkpoint_resume when you are the next agent.`
277
+ : 'No checkpoints yet.',
278
+ `Recent: ${state.recent_events.map((e) => e.event_type).join(', ')}`,
279
+ ];
280
+ return { text: lines.join('\n') };
281
+ },
282
+ },
283
+ {
284
+ name: 'checkpoint_create',
285
+ description: 'Create a handoff checkpoint: compiles (or links) context, builds an evidence-backed passport from the ledger, and records it for cross-agent resume.',
286
+ inputSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ project_id: { type: 'string' },
290
+ title: { type: 'string', description: 'Short checkpoint title' },
291
+ summary: { type: 'string', description: 'What was done and where things stand' },
292
+ next_steps: { type: 'array', items: { type: 'string' }, description: 'What the next agent should do' },
293
+ blockers: { type: 'array', items: { type: 'string' }, description: 'Known blockers (auto-derived if omitted)' },
294
+ agent: { type: 'string', description: 'Agent key creating the checkpoint, e.g. "opencode"' },
295
+ session_id: { type: 'string', description: 'Source session id' },
296
+ context_version_id: { type: 'string', description: 'Link an existing context version instead of compiling fresh' },
297
+ token_budget: { type: 'number', description: 'Budget for the fresh compile (default 6000)' },
298
+ },
299
+ required: ['project_id', 'title', 'summary'],
300
+ },
301
+ async handler(args, deps) {
302
+ const payload = {
303
+ project_id: requireString(args, 'project_id'),
304
+ title: requireString(args, 'title'),
305
+ summary: requireString(args, 'summary'),
306
+ };
307
+ const nextSteps = optionalStringArray(args, 'next_steps', 20);
308
+ if (nextSteps !== undefined)
309
+ payload.next_steps = nextSteps;
310
+ const blockers = optionalStringArray(args, 'blockers', 20);
311
+ if (blockers !== undefined)
312
+ payload.blockers = blockers;
313
+ const agent = optionalString(args, 'agent');
314
+ if (agent !== undefined)
315
+ payload.agent = agent;
316
+ const sessionId = optionalString(args, 'session_id');
317
+ if (sessionId !== undefined)
318
+ payload.session_id = sessionId;
319
+ const contextVersionId = optionalString(args, 'context_version_id');
320
+ if (contextVersionId !== undefined)
321
+ payload.context_version_id = contextVersionId;
322
+ const tokenBudget = optionalNumber(args, 'token_budget');
323
+ if (tokenBudget !== undefined)
324
+ payload.token_budget = Math.min(Math.max(tokenBudget, 64), 50000);
325
+ const res = await deps.callApi('POST', '/v1/checkpoints', payload);
326
+ if (res.status >= 400)
327
+ return apiErrorText(res.status, res.body);
328
+ const checkpoint = res.body;
329
+ return {
330
+ text: `Checkpoint ${checkpoint.id} [${checkpoint.status}]: ${bounded(checkpoint.title)} — context ${checkpoint.context_version_id} (${checkpoint.token_count} tokens, ${checkpoint.item_count} items)`,
331
+ };
332
+ },
333
+ },
334
+ {
335
+ name: 'entity_observe',
336
+ description: 'Report code entities (files, modules, symbols, endpoints, tables, concepts) and relations (imports, calls, defines, reads, depends_on…) — builds the project graph that links memories and files across agents. Idempotent.',
337
+ inputSchema: {
338
+ type: 'object',
339
+ properties: {
340
+ project_id: { type: 'string', description: 'MayaMemo project id' },
341
+ entities: {
342
+ type: 'array',
343
+ items: {
344
+ type: 'object',
345
+ properties: { kind: { type: 'string' }, name: { type: 'string' } },
346
+ required: ['kind', 'name'],
347
+ },
348
+ description: 'Entities observed (max 50)',
349
+ },
350
+ relations: {
351
+ type: 'array',
352
+ items: {
353
+ type: 'object',
354
+ properties: {
355
+ source: { type: 'object', properties: { kind: { type: 'string' }, name: { type: 'string' } }, required: ['kind', 'name'] },
356
+ target: { type: 'object', properties: { kind: { type: 'string' }, name: { type: 'string' } }, required: ['kind', 'name'] },
357
+ kind: { type: 'string' },
358
+ confidence: { type: 'number' },
359
+ },
360
+ required: ['source', 'target', 'kind'],
361
+ },
362
+ description: 'Relations between entities (max 100)',
363
+ },
364
+ },
365
+ required: ['project_id'],
366
+ },
367
+ async handler(args, deps) {
368
+ const payload = { project_id: requireString(args, 'project_id') };
369
+ const entities = entityRefs(args, 'entities', 50);
370
+ if (entities)
371
+ payload.entities = entities;
372
+ const relations = args.relations;
373
+ if (Array.isArray(relations) && relations.length > 0) {
374
+ if (relations.length > 100)
375
+ throw new ToolInputError('Argument relations exceeds 100 entries');
376
+ payload.relations = relations;
377
+ }
378
+ if (!payload.entities && !payload.relations) {
379
+ throw new ToolInputError('Provide at least one entity or relation');
380
+ }
381
+ const res = await deps.callApi('POST', '/v1/entities/observe', payload);
382
+ if (res.status >= 400)
383
+ return apiErrorText(res.status, res.body);
384
+ const body = res.body;
385
+ return {
386
+ text: `Graph updated: ${body.entity_count} entities (${body.created_entities} new), ${body.relation_count} relations.`,
387
+ };
388
+ },
389
+ },
390
+ {
391
+ name: 'graph_query',
392
+ description: 'Query the project graph: list/search entities by name or kind, or get the neighbors (depth 1-2) of one entity — files, modules, symbols and how they relate.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ properties: {
396
+ project_id: { type: 'string', description: 'MayaMemo project id (required when listing)' },
397
+ q: { type: 'string', description: 'Name search (substring)' },
398
+ kind: { type: 'string', description: 'Filter by entity kind (file, module, symbol…)' },
399
+ entity_id: { type: 'string', description: 'Entity id — switches to neighbor mode' },
400
+ depth: { type: 'number', description: 'Neighbor depth 1-2 (default 1)' },
401
+ },
402
+ required: [],
403
+ },
404
+ async handler(args, deps) {
405
+ const entityId = optionalString(args, 'entity_id');
406
+ if (entityId) {
407
+ const depth = Math.min(Math.max(optionalNumber(args, 'depth') ?? 1, 1), 2);
408
+ const res = await deps.callApi('GET', `/v1/entities/${entityId}/neighbors?depth=${depth}`);
409
+ if (res.status >= 400)
410
+ return apiErrorText(res.status, res.body);
411
+ const graph = res.body;
412
+ const lines = graph.nodes
413
+ .slice(0, MAX_SEARCH_RESULTS)
414
+ .map((n) => `- [d${n.depth}] ${n.kind} ${n.name}`);
415
+ return {
416
+ text: `Neighbors of ${graph.root.kind} ${graph.root.name} (${graph.nodes.length} nodes, ${graph.edges.length} edges${graph.truncated ? ', truncated' : ''}):\n${lines.join('\n')}`,
417
+ };
418
+ }
419
+ const projectId = requireString(args, 'project_id');
420
+ const params = new URLSearchParams();
421
+ const q = optionalString(args, 'q');
422
+ if (q)
423
+ params.set('q', q);
424
+ const kind = optionalString(args, 'kind');
425
+ if (kind)
426
+ params.set('kind', kind);
427
+ params.set('limit', '25');
428
+ const res = await deps.callApi('GET', `/v1/projects/${projectId}/entities?${params.toString()}`);
429
+ if (res.status >= 400)
430
+ return apiErrorText(res.status, res.body);
431
+ const body = res.body;
432
+ if (body.entities.length === 0)
433
+ return { text: 'No entities match.' };
434
+ const lines = body.entities.map((e) => `- ${e.kind} ${e.name} (id ${e.id}, ${e.relationCount} relations, ${e.memoryCount} memories)`);
435
+ return { text: `${body.entities.length} entities:\n${lines.join('\n')}` };
436
+ },
437
+ },
438
+ {
439
+ name: 'attachment_upload',
440
+ description: 'Upload a file or image to the project (base64). Content-addressed: re-uploading identical bytes returns the existing attachment. Optionally link it to a memory.',
441
+ inputSchema: {
442
+ type: 'object',
443
+ properties: {
444
+ project_id: { type: 'string', description: 'MayaMemo project id' },
445
+ filename: { type: 'string', description: 'File name (drives text/code detection)' },
446
+ content_base64: { type: 'string', description: 'Raw file bytes, base64-encoded (max 10 MB decoded)' },
447
+ mime: { type: 'string', description: 'Optional MIME type (defaults to application/octet-stream)' },
448
+ memory_id: { type: 'string', description: 'Optional memory id to link the attachment to' },
449
+ },
450
+ required: ['project_id', 'filename', 'content_base64'],
451
+ },
452
+ async handler(args, deps) {
453
+ const payload = {
454
+ project_id: requireString(args, 'project_id'),
455
+ filename: requireString(args, 'filename'),
456
+ content_base64: requireString(args, 'content_base64'),
457
+ };
458
+ const mime = optionalString(args, 'mime');
459
+ if (mime)
460
+ payload.mime = mime;
461
+ const memoryId = optionalString(args, 'memory_id');
462
+ if (memoryId)
463
+ payload.memory_id = memoryId;
464
+ const res = await deps.callApi('POST', '/v1/attachments', payload);
465
+ if (res.status >= 400)
466
+ return apiErrorText(res.status, res.body);
467
+ const body = res.body;
468
+ const linked = body.memoryId ? `, linked to memory ${body.memoryId}` : '';
469
+ return {
470
+ text: `${body.deduped ? 'Reused existing' : 'Uploaded'} ${body.kind} attachment ${body.id} (${body.filename}, ${body.sizeBytes} bytes${linked})`,
471
+ };
472
+ },
473
+ },
474
+ {
475
+ name: 'attachment_describe',
476
+ description: 'Describe an image attachment with the configured vision model and (by default) save the description as a durable project memory.',
477
+ inputSchema: {
478
+ type: 'object',
479
+ properties: {
480
+ attachment_id: { type: 'string', description: 'Image attachment id' },
481
+ prompt: { type: 'string', description: 'Optional custom description prompt' },
482
+ create_memory: { type: 'boolean', description: 'Set false to return the description without saving a memory' },
483
+ },
484
+ required: ['attachment_id'],
485
+ },
486
+ async handler(args, deps) {
487
+ const attachmentId = requireString(args, 'attachment_id');
488
+ const payload = {};
489
+ const prompt = optionalString(args, 'prompt');
490
+ if (prompt)
491
+ payload.prompt = prompt;
492
+ if (typeof args.create_memory === 'boolean')
493
+ payload.create_memory = args.create_memory;
494
+ const res = await deps.callApi('POST', `/v1/attachments/${encodeURIComponent(attachmentId)}/describe`, payload);
495
+ if (res.status >= 400)
496
+ return apiErrorText(res.status, res.body);
497
+ const body = res.body;
498
+ const memory = body.memory_id ? `\nSaved memory ${body.memory_id}${body.deduped ? ' (deduped)' : ''}.` : '';
499
+ return { text: `Description: ${bounded(body.description)}${memory}` };
500
+ },
501
+ },
502
+ {
503
+ name: 'memory_feedback',
504
+ description: 'Vote 👍 (up) or 👎 (down) on a memory: did it help the task? Votes are recorded in the ledger and nudge retrieval ranking (up = rank higher, down = rank lower).',
505
+ inputSchema: {
506
+ type: 'object',
507
+ properties: {
508
+ memory_id: { type: 'string', description: 'Memory id' },
509
+ signal: { type: 'string', description: '"up" or "down"' },
510
+ reason: { type: 'string', description: 'Optional short reason (audited in the event payload)' },
511
+ },
512
+ required: ['memory_id', 'signal'],
513
+ },
514
+ async handler(args, deps) {
515
+ const memoryId = requireString(args, 'memory_id');
516
+ const signal = requireString(args, 'signal');
517
+ if (signal !== 'up' && signal !== 'down') {
518
+ return { text: 'signal must be "up" or "down".', isError: true };
519
+ }
520
+ const payload = { signal };
521
+ const reason = optionalString(args, 'reason');
522
+ if (reason)
523
+ payload.reason = reason;
524
+ const res = await deps.callApi('POST', `/v1/memories/${encodeURIComponent(memoryId)}/feedback`, payload);
525
+ if (res.status >= 400)
526
+ return apiErrorText(res.status, res.body);
527
+ const body = res.body;
528
+ return {
529
+ text: `Recorded ${signal === 'up' ? '👍' : '👎'} for memory ${memoryId} (up ${body.feedback_up} / down ${body.feedback_down}, utility ${body.utility}).`,
530
+ };
531
+ },
532
+ },
533
+ {
534
+ name: 'session_start',
535
+ description: 'Start a work session for this project: sessions anchor events, checkpoints and dashboard activity, and make handoff metrics count. Call once when starting a work block and close it with session_end.',
536
+ inputSchema: {
537
+ type: 'object',
538
+ properties: {
539
+ project_id: { type: 'string', description: 'MayaMemo project id (from the project mapping)' },
540
+ agent: { type: 'string', description: 'Your agent name (e.g. opencode, claude-code, codex)' },
541
+ branch: { type: 'string', description: 'Optional git branch this session works on' },
542
+ summary: { type: 'string', description: 'Optional one-line intent for the session' },
543
+ },
544
+ required: ['project_id'],
545
+ },
546
+ async handler(args, deps) {
547
+ const payload = { project_id: requireString(args, 'project_id') };
548
+ const agent = optionalString(args, 'agent');
549
+ if (agent)
550
+ payload.agent = agent;
551
+ const branch = optionalString(args, 'branch');
552
+ if (branch)
553
+ payload.branch = branch;
554
+ const summary = optionalString(args, 'summary');
555
+ if (summary)
556
+ payload.summary = summary;
557
+ const res = await deps.callApi('POST', '/v1/sessions', payload);
558
+ if (res.status >= 400)
559
+ return apiErrorText(res.status, res.body);
560
+ const session = res.body;
561
+ return {
562
+ text: `Started session ${session.id}${agent ? ` (agent ${agent})` : ''}. Close it with session_end when the work block is done.`,
563
+ };
564
+ },
565
+ },
566
+ {
567
+ name: 'session_end',
568
+ description: 'Complete a work session started with session_start (optionally leave a summary for the next agent).',
569
+ inputSchema: {
570
+ type: 'object',
571
+ properties: {
572
+ session_id: { type: 'string', description: 'Session id returned by session_start' },
573
+ summary: { type: 'string', description: 'Optional summary of what the session did' },
574
+ },
575
+ required: ['session_id'],
576
+ },
577
+ async handler(args, deps) {
578
+ const sessionId = requireString(args, 'session_id');
579
+ const payload = {};
580
+ const summary = optionalString(args, 'summary');
581
+ if (summary)
582
+ payload.summary = summary;
583
+ const res = await deps.callApi('POST', `/v1/sessions/${encodeURIComponent(sessionId)}/complete`, payload);
584
+ if (res.status >= 400)
585
+ return apiErrorText(res.status, res.body);
586
+ return { text: `Completed session ${sessionId}.` };
587
+ },
588
+ },
589
+ {
590
+ name: 'checkpoint_resume',
591
+ description: 'Take over from a checkpoint: records the cross-agent handoff (so handoff metrics count it), starts a resume session and returns the full resume briefing. Read the briefing before touching code.',
592
+ inputSchema: {
593
+ type: 'object',
594
+ properties: {
595
+ checkpoint_id: { type: 'string', description: 'Checkpoint id (e.g. from project_state)' },
596
+ agent: { type: 'string', description: 'Your agent name (recorded as the resuming agent)' },
597
+ note: { type: 'string', description: 'Optional short note stored with the resume event' },
598
+ },
599
+ required: ['checkpoint_id', 'agent'],
600
+ },
601
+ async handler(args, deps) {
602
+ const checkpointId = requireString(args, 'checkpoint_id');
603
+ const agent = requireString(args, 'agent');
604
+ const payload = { agent };
605
+ const note = optionalString(args, 'note');
606
+ if (note)
607
+ payload.note = note;
608
+ const res = await deps.callApi('POST', `/v1/checkpoints/${encodeURIComponent(checkpointId)}/resume`, payload);
609
+ if (res.status >= 400)
610
+ return apiErrorText(res.status, res.body);
611
+ const body = res.body;
612
+ const sessionLine = body.resumed_session
613
+ ? `Resumed checkpoint ${checkpointId} as session ${body.resumed_session.id} (agent ${body.resumed_session.agent ?? agent}).`
614
+ : `Resumed checkpoint ${checkpointId} (agent ${agent}).`;
615
+ return { text: `${sessionLine}\n\n${body.briefing_markdown ?? '(no briefing returned)'}` };
616
+ },
617
+ },
618
+ ];
619
+ export function findTool(name) {
620
+ return TOOLS.find((tool) => tool.name === name);
621
+ }
622
+ export const MCP_TOOL_NAMES = TOOLS.map((tool) => tool.name);
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@mayamemo/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MayaMemo MCP server — durable project memory, checkpoints and handoffs for coding agents over stdio.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "private": false,
8
+ "bin": {
9
+ "mayamemo-mcp": "dist/bin.js"
10
+ },
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Coin1511/mayamemo.git",
23
+ "directory": "packages/mcp"
24
+ },
25
+ "homepage": "https://github.com/Coin1511/mayamemo#readme",
26
+ "keywords": ["mcp", "model-context-protocol", "ai-agents", "coding-agents", "memory", "context", "opencode"],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "lint": "eslint .",
31
+ "test": "vitest run"
32
+ },
33
+ "devDependencies": {
34
+ "vitest": "^4.1.11"
35
+ }
36
+ }