Add MCP sdtio interface

This commit is contained in:
hzrd149
2025-03-27 11:32:20 +00:00
parent d4574cbeaf
commit 635b033f5c
15 changed files with 3264 additions and 2151 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel-bakery": minor
---
Add MCP sdtio interface

View File

@@ -10,6 +10,7 @@
"prepack": "pnpm build", "prepack": "pnpm build",
"start": "node .", "start": "node .",
"dev": "nodemon --loader @swc-node/register/esm src/index.ts", "dev": "nodemon --loader @swc-node/register/esm src/index.ts",
"mcp": "mcp-inspector node . --mcp --port 8080",
"build": "tsc", "build": "tsc",
"test": "vitest run", "test": "vitest run",
"format": "prettier -w ." "format": "prettier -w ."
@@ -26,6 +27,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@diva.exchange/i2p-sam": "^5.4.2", "@diva.exchange/i2p-sam": "^5.4.2",
"@modelcontextprotocol/sdk": "^1.8.0",
"@noble/hashes": "^1.7.1", "@noble/hashes": "^1.7.1",
"@satellite-earth/core": "^0.5.0", "@satellite-earth/core": "^0.5.0",
"applesauce-core": "^0.11.0", "applesauce-core": "^0.11.0",
@@ -58,10 +60,12 @@
"streamx": "^2.22.0", "streamx": "^2.22.0",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"ws": "^8.18.1" "ws": "^8.18.1",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.28.1", "@changesets/cli": "^2.28.1",
"@modelcontextprotocol/inspector": "^0.7.0",
"@swc-node/register": "^1.10.9", "@swc-node/register": "^1.10.9",
"@swc/core": "^1.10.18", "@swc/core": "^1.10.18",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",

5261
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@ import secrets from "../services/secrets.js";
import config from "../services/config.js"; import config from "../services/config.js";
import logStore from "../services/log-store.js"; import logStore from "../services/log-store.js";
import stateManager from "../services/state.js"; import stateManager from "../services/state.js";
import sqliteEventStore from "../services/event-cache.js"; import eventCache from "../services/event-cache.js";
import { inboundNetwork, outboundNetwork } from "../services/network.js"; import { inboundNetwork, outboundNetwork } from "../services/network.js";
import { server } from "../services/server.js"; import { server } from "../services/server.js";
import { SQLiteEventStore } from "../sqlite/event-store.js"; import { SQLiteEventStore } from "../sqlite/event-store.js";
@@ -156,7 +156,7 @@ export default class App extends EventEmitter<EventMap> {
}); });
// Initialize the event store // Initialize the event store
this.eventStore = sqliteEventStore; this.eventStore = eventCache;
// setup decryption cache // setup decryption cache
this.decryptionCache = new DecryptionCache(this.database.db); this.decryptionCache = new DecryptionCache(this.database.db);

12
src/args.ts Normal file
View File

@@ -0,0 +1,12 @@
import { parseArgs, ParseArgsConfig } from "node:util";
const config = {
options: {
mcp: { type: "boolean" },
port: { type: "string", short: "p" },
},
} as const satisfies ParseArgsConfig;
const args = parseArgs(config);
export default args;

View File

@@ -1,16 +1,17 @@
import "dotenv/config"; import "dotenv/config";
import { mkdirp } from "mkdirp"; import { mkdirp } from "mkdirp";
import { OUTBOUND_PROXY_TYPES } from "./const.js";
import { normalizeToHexPubkey } from "./helpers/nip19.js";
import { normalizeURL } from "applesauce-core/helpers"; import { normalizeURL } from "applesauce-core/helpers";
import { OUTBOUND_PROXY_TYPES } from "./const.js";
import { normalizeToHexPubkey } from "./helpers/nip19.js";
import args from "./args.js";
export const OWNER_PUBKEY = process.env.OWNER_PUBKEY ? normalizeToHexPubkey(process.env.OWNER_PUBKEY) : undefined; export const OWNER_PUBKEY = process.env.OWNER_PUBKEY ? normalizeToHexPubkey(process.env.OWNER_PUBKEY) : undefined;
export const PUBLIC_ADDRESS = process.env.PUBLIC_ADDRESS; export const PUBLIC_ADDRESS = process.env.PUBLIC_ADDRESS;
export const DATA_PATH = process.env.DATA_PATH || "./data"; export const DATA_PATH = process.env.DATA_PATH || "./data";
await mkdirp(DATA_PATH); await mkdirp(DATA_PATH);
export const PORT = parseInt(process.env.PORT ?? "") || 3000; export const PORT = parseInt(args.values.port ?? process.env.PORT ?? "") || 3000;
// I2P config // I2P config
export const I2P_PROXY = process.env.I2P_PROXY; export const I2P_PROXY = process.env.I2P_PROXY;
@@ -36,3 +37,5 @@ export const COMMON_CONTACT_RELAYS = process.env.COMMON_CONTACT_RELAYS
: ["wss://purplepag.es", "wss://user.kindpag.es"].map(normalizeURL); : ["wss://purplepag.es", "wss://user.kindpag.es"].map(normalizeURL);
export const IS_DEV = process.env.NODE_ENV === "development"; export const IS_DEV = process.env.NODE_ENV === "development";
export const IS_MCP = args.values.mcp;

View File

@@ -1,12 +1,15 @@
import { getPubkeyFromDecodeResult, isHexKey } from "applesauce-core/helpers"; import { getPubkeyFromDecodeResult, isHexKey } from "applesauce-core/helpers";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
export function normalizeToHexPubkey(hex: string) { export function normalizeToHexPubkey(hex: string, require?: boolean): string | null;
export function normalizeToHexPubkey(hex: string, require: true): string;
export function normalizeToHexPubkey(hex: string, require = false): string | null {
if (isHexKey(hex)) return hex; if (isHexKey(hex)) return hex;
try { try {
const decode = nip19.decode(hex); const decode = nip19.decode(hex);
return getPubkeyFromDecodeResult(decode) ?? null; return getPubkeyFromDecodeResult(decode) ?? null;
} catch (error) { } catch (error) {
return null; if (require) throw error;
else return null;
} }
} }

View File

@@ -9,7 +9,7 @@ import duration from "dayjs/plugin/duration.js";
import localizedFormat from "dayjs/plugin/localizedFormat.js"; import localizedFormat from "dayjs/plugin/localizedFormat.js";
import App from "./app/index.js"; import App from "./app/index.js";
import { PUBLIC_ADDRESS } from "./env.js"; import { PUBLIC_ADDRESS, IS_MCP } from "./env.js";
import { addListener, logger } from "./logger.js"; import { addListener, logger } from "./logger.js";
import { pathExists } from "./helpers/fs.js"; import { pathExists } from "./helpers/fs.js";
@@ -67,12 +67,21 @@ process.on("unhandledRejection", (reason, promise) => {
// start the app // start the app
await app.start(); await app.start();
// Setup MCP interface on stdio
if (IS_MCP) {
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
const { default: server } = await import("./services/mcp/index.js");
// connect MCP server to stdio
const transport = new StdioServerTransport();
await server.connect(transport);
}
// shutdown process // shutdown process
async function shutdown() { async function shutdown() {
logger("shutting down"); logger("shutting down");
await app.stop(); await app.stop();
process.exit(0); process.exit(0);
} }
process.on("SIGINT", shutdown); process.on("SIGINT", shutdown);
@@ -80,10 +89,10 @@ process.on("SIGTERM", shutdown);
// catch unhandled errors // catch unhandled errors
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error); if (!IS_MCP) console.error("Uncaught Exception:", error);
}); });
// 2. Catch unhandled promise rejections // 2. Catch unhandled promise rejections
process.on("unhandledRejection", (reason, promise) => { process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Promise Rejection:", reason); if (!IS_MCP) console.error("Unhandled Promise Rejection:", reason);
}); });

View File

@@ -1,4 +1,5 @@
import debug, { Debugger } from "debug"; import debug, { Debugger } from "debug";
import { IS_MCP } from "./env.js";
if (!process.env.DEBUG) debug.enable("bakery,bakery:*"); if (!process.env.DEBUG) debug.enable("bakery,bakery:*");
@@ -17,7 +18,9 @@ debug.log = function (this: Debugger, ...args: any[]) {
for (const listener of listeners) { for (const listener of listeners) {
listener(this, ...args); listener(this, ...args);
} }
console.log.apply(this, args);
// only log to console if not running in MCP mode
if (!IS_MCP) console.log.apply(this, args);
}; };
export const logger = debug("bakery"); export const logger = debug("bakery");

View File

@@ -1,7 +1,7 @@
import { SQLiteEventStore } from "../sqlite/event-store.js"; import { SQLiteEventStore } from "../sqlite/event-store.js";
import database from "./database.js"; import database from "./database.js";
const sqliteEventStore = new SQLiteEventStore(database.db); const eventCache = new SQLiteEventStore(database.db);
await sqliteEventStore.setup(); await eventCache.setup();
export default sqliteEventStore; export default eventCache;

View File

@@ -5,11 +5,11 @@ import { ReplaceableLoader, RequestLoader } from "applesauce-loaders/loaders";
import { COMMON_CONTACT_RELAYS } from "../env.js"; import { COMMON_CONTACT_RELAYS } from "../env.js";
import { rxNostr } from "./rx-nostr.js"; import { rxNostr } from "./rx-nostr.js";
import sqliteEventStore from "./event-cache.js"; import eventCache from "./event-cache.js";
import { eventStore, queryStore } from "./stores.js"; import { eventStore, queryStore } from "./stores.js";
function cacheRequest(filters: Filter[]) { function cacheRequest(filters: Filter[]) {
const events = sqliteEventStore.getEventsForFilters(filters); const events = eventCache.getEventsForFilters(filters);
return from(events).pipe(tap(markFromCache)); return from(events).pipe(tap(markFromCache));
} }
@@ -20,7 +20,7 @@ replaceableLoader.subscribe((packet) => {
const event = eventStore.add(packet.event, packet.from); const event = eventStore.add(packet.event, packet.from);
// save it to the cache if its new // save it to the cache if its new
if (!isFromCache(event)) sqliteEventStore.addEvent(event); if (!isFromCache(event)) eventCache.addEvent(event);
}); });
export const requestLoader = new RequestLoader(queryStore); export const requestLoader = new RequestLoader(queryStore);

View File

@@ -0,0 +1,5 @@
import "./resources.js";
import "./tools.js";
import server from "./server.js";
export default server;

View File

@@ -0,0 +1,35 @@
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import server from "./server.js";
import config from "../config.js";
import { normalizeToHexPubkey } from "../../helpers/nip19.js";
import { requestLoader } from "../loaders.js";
server.resource("owner pubkey", "pubkey://owner", async (uri) => ({
contents: [
{
uri: uri.href,
text: config.data.owner ?? "undefined",
},
],
}));
server.resource(
"user profile",
new ResourceTemplate("users://{pubkey}/profile", { list: undefined }),
async (uri, { pubkey }) => {
if (typeof pubkey !== "string") throw new Error("Pubkey must be a string");
pubkey = normalizeToHexPubkey(pubkey, true);
const profile = await requestLoader.profile({ pubkey });
return {
contents: [
{
uri: uri.href,
text: JSON.stringify(profile, null, 2),
},
],
};
},
);

View File

@@ -0,0 +1,8 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({
name: "Bakery",
version: "1.0.0",
});
export default server;

27
src/services/mcp/tools.ts Normal file
View File

@@ -0,0 +1,27 @@
import z from "zod";
import { kinds } from "nostr-tools";
import server from "./server.js";
import eventCache from "../event-cache.js";
server.tool(
"Search events",
"Search events by kind and query",
{
query: z.string(),
kind: z.number().optional().default(kinds.ShortTextNote),
limit: z.number().optional().default(50),
},
async ({ query, kind, limit }) => {
const events = eventCache.getEventsForFilters([{ kinds: [kind], search: query, limit }]);
return {
content: events.map((event) => {
return {
type: "text",
text: JSON.stringify(event),
};
}),
};
},
);