replace all pubkey inputs to user inputs in mcp

This commit is contained in:
hzrd149
2025-04-09 12:53:41 +01:00
parent ab75238ae6
commit f5b4770e6f
10 changed files with 471 additions and 775 deletions

View File

@@ -41,7 +41,6 @@
"applesauce-loaders": "next", "applesauce-loaders": "next",
"applesauce-relay": "next", "applesauce-relay": "next",
"applesauce-signers": "next", "applesauce-signers": "next",
"nostr-bakery-common": "^0.1.0",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"blossom-client-sdk": "^2.1.1", "blossom-client-sdk": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -63,6 +62,7 @@
"mkdirp": "^3.0.1", "mkdirp": "^3.0.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"node-graceful-shutdown": "^1.1.5", "node-graceful-shutdown": "^1.1.5",
"nostr-bakery-common": "^0.1.0",
"nostr-tools": "^2.12.0", "nostr-tools": "^2.12.0",
"pac-proxy-agent": "^7.2.0", "pac-proxy-agent": "^7.2.0",
"process-streams": "^1.0.3", "process-streams": "^1.0.3",
@@ -77,7 +77,7 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.28.1", "@changesets/cli": "^2.28.1",
"@modelcontextprotocol/inspector": "^0.7.0", "@modelcontextprotocol/inspector": "^0.8.2",
"@swc-node/register": "^1.10.10", "@swc-node/register": "^1.10.10",
"@swc/core": "^1.11.18", "@swc/core": "^1.11.18",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",

View File

@@ -10,23 +10,17 @@ import cors from "cors";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { NIP_11_SOFTWARE_URL, SENSITIVE_KINDS } from "../const.js"; import { NIP_11_SOFTWARE_URL } from "../const.js";
import { OWNER_PUBKEY, BAKERY_PORT } from "../env.js"; import { OWNER_PUBKEY, BAKERY_PORT } from "../env.js";
import ControlApi from "../modules/control/control-api.js";
import DirectMessageManager from "../modules/direct-message-manager.js"; import DirectMessageManager from "../modules/direct-message-manager.js";
import DirectMessageActions from "../modules/control/dm-actions.js";
import AddressBook from "../modules/address-book.js"; import AddressBook from "../modules/address-book.js";
import NotificationsManager from "../modules/notifications/notifications-manager.js"; import NotificationsManager from "../modules/notifications/notifications-manager.js";
import NotificationActions from "../modules/control/notification-actions.js";
import ProfileBook from "../modules/profile-book.js"; import ProfileBook from "../modules/profile-book.js";
import ContactBook from "../modules/contact-book.js"; import ContactBook from "../modules/contact-book.js";
import CautiousPool from "../modules/cautious-pool.js"; import CautiousPool from "../modules/cautious-pool.js";
import RemoteAuthActions from "../modules/control/remote-auth-actions.js";
import LogStore from "../modules/log-store/log-store.js"; import LogStore from "../modules/log-store/log-store.js";
import DecryptionCache from "../modules/decryption-cache/decryption-cache.js"; import DecryptionCache from "../modules/decryption-cache/decryption-cache.js";
import DecryptionCacheActions from "../modules/control/decryption-cache.js";
import LogsActions from "../modules/control/logs-actions.js";
import ApplicationStateManager from "../modules/application-state/manager.js"; import ApplicationStateManager from "../modules/application-state/manager.js";
import InboundNetworkManager from "../modules/network/inbound/index.js"; import InboundNetworkManager from "../modules/network/inbound/index.js";
import OutboundNetworkManager from "../modules/network/outbound/index.js"; import OutboundNetworkManager from "../modules/network/outbound/index.js";
@@ -71,7 +65,6 @@ export default class App extends EventEmitter<EventMap> {
eventStore: SQLiteEventStore; eventStore: SQLiteEventStore;
logStore: LogStore; logStore: LogStore;
relay: NostrRelay; relay: NostrRelay;
control: ControlApi;
pool: CautiousPool; pool: CautiousPool;
addressBook: AddressBook; addressBook: AddressBook;
profileBook: ProfileBook; profileBook: ProfileBook;
@@ -167,21 +160,6 @@ export default class App extends EventEmitter<EventMap> {
if (config.owner) this.directMessageManager.watchInbox(config.owner); if (config.owner) this.directMessageManager.watchInbox(config.owner);
}); });
// API for controlling the node
this.control = new ControlApi(this);
this.control.registerHandler(new DirectMessageActions(this));
this.control.registerHandler(new NotificationActions(this));
this.control.registerHandler(new RemoteAuthActions(this));
this.control.registerHandler(new DecryptionCacheActions(this));
this.control.registerHandler(new LogsActions(this));
// connect control api to websocket server
this.control.attachToServer(this.wss);
// if process has an RPC interface, attach control api to it
if (process.send) this.control.attachToProcess(process);
const connection = onConnection(this.wss); const connection = onConnection(this.wss);
// queries // queries
@@ -251,13 +229,6 @@ export default class App extends EventEmitter<EventMap> {
return true; return true;
}; };
// when the owner NIP-42 authenticates with the relay pass it along to the control
this.relay.on("socket:auth", (ws, auth) => {
if (auth.pubkey === this.config.data.owner) {
this.control.authenticatedConnections.add(ws);
}
});
// if socket is unauthenticated only allow owner's events and incoming DMs // if socket is unauthenticated only allow owner's events and incoming DMs
this.relay.registerEventHandler((ctx, next) => { this.relay.registerEventHandler((ctx, next) => {
const auth = ctx.relay.getSocketAuth(ctx.socket); const auth = ctx.relay.getSocketAuth(ctx.socket);
@@ -287,22 +258,22 @@ export default class App extends EventEmitter<EventMap> {
}); });
// block subscriptions for sensitive kinds unless NIP-42 auth or Auth Code // block subscriptions for sensitive kinds unless NIP-42 auth or Auth Code
this.relay.registerSubscriptionFilter((ctx, next) => { // this.relay.registerSubscriptionFilter((ctx, next) => {
// always allow if authenticated with auth code // // always allow if authenticated with auth code
const isAuthenticatedWithAuthCode = this.control.authenticatedConnections.has(ctx.socket); // const isAuthenticatedWithAuthCode = this.control.authenticatedConnections.has(ctx.socket);
if (isAuthenticatedWithAuthCode) return next(); // if (isAuthenticatedWithAuthCode) return next();
const hasSensitiveKinds = ctx.filters.some( // const hasSensitiveKinds = ctx.filters.some(
(filter) => filter.kinds && SENSITIVE_KINDS.some((k) => filter.kinds?.includes(k)), // (filter) => filter.kinds && SENSITIVE_KINDS.some((k) => filter.kinds?.includes(k)),
); // );
if (hasSensitiveKinds) { // if (hasSensitiveKinds) {
const auth = ctx.relay.getSocketAuth(ctx.socket); // const auth = ctx.relay.getSocketAuth(ctx.socket);
if (!auth) throw new Error(ctx.relay.makeAuthRequiredReason("Cant view sensitive events without auth")); // if (!auth) throw new Error(ctx.relay.makeAuthRequiredReason("Cant view sensitive events without auth"));
} // }
return next(); // return next();
}); // });
// Handle possible additional actions when the event store receives a new message // Handle possible additional actions when the event store receives a new message
this.eventStore.on("event:inserted", (event) => { this.eventStore.on("event:inserted", (event) => {

View File

@@ -1,130 +0,0 @@
import { WebSocket, WebSocketServer } from "ws";
import { type IncomingMessage } from "http";
import { ControlResponse } from "@satellite-earth/core/types/control-api/index.js";
import type App from "../../app/index.js";
import { logger } from "../../logger.js";
export type ControlMessage = ["CONTROL", string, string, ...any[]];
export interface ControlMessageHandler {
app: App;
name: string;
handleConnection?(ws: WebSocket | NodeJS.Process): void;
handleDisconnect?(socket: WebSocket): void;
handleMessage(sock: WebSocket | NodeJS.Process, message: ControlMessage): boolean | Promise<boolean>;
}
/** handles web socket connections and 'CONTROL' messages */
export default class ControlApi {
app: App;
auth?: string;
log = logger.extend("ControlApi");
handlers = new Map<string, ControlMessageHandler>();
authenticatedConnections = new Set<WebSocket | NodeJS.Process>();
constructor(app: App, auth?: string) {
this.app = app;
this.auth = auth;
}
registerHandler(handler: ControlMessageHandler) {
this.handlers.set(handler.name, handler);
}
unregisterHandler(handler: ControlMessageHandler) {
this.handlers.delete(handler.name);
}
/** start listening for incoming ws connections */
attachToServer(wss: WebSocketServer) {
wss.on("connection", this.handleConnection.bind(this));
}
handleConnection(ws: WebSocket, req: IncomingMessage) {
ws.on("message", (data, isBinary) => {
this.handleRawMessage(ws, data as Buffer);
});
for (const [id, handler] of this.handlers) {
handler.handleConnection?.(ws);
}
ws.once("close", () => this.handleDisconnect(ws));
}
handleDisconnect(ws: WebSocket) {
this.authenticatedConnections.delete(ws);
for (const [id, handler] of this.handlers) {
handler.handleDisconnect?.(ws);
}
}
attachToProcess(p: NodeJS.Process) {
p.on("message", (message) => {
if (
Array.isArray(message) &&
message[0] === "CONTROL" &&
typeof message[1] === "string" &&
typeof message[2] === "string"
) {
this.handleMessage(p, message as ControlMessage);
}
});
for (const [id, handler] of this.handlers) {
handler.handleConnection?.(p);
}
}
/** handle a ws message */
async handleRawMessage(ws: WebSocket | NodeJS.Process, message: Buffer) {
try {
const data = JSON.parse(message.toString()) as string[];
try {
if (
Array.isArray(data) &&
data[0] === "CONTROL" &&
typeof data[1] === "string" &&
typeof data[2] === "string"
) {
if (this.authenticatedConnections.has(ws) || data[1] === "AUTH") {
await this.handleMessage(ws, data as ControlMessage);
}
}
} catch (err) {
this.log("Failed to handle Control message", message.toString("utf-8"));
this.log(err);
}
} catch (error) {
// failed to parse JSON, do nothing
}
}
/** handle a ['CONTROL', ...] message */
async handleMessage(sock: WebSocket | NodeJS.Process, message: ControlMessage) {
// handle ['CONTROL', 'AUTH', <code>] messages
if (message[1] === "AUTH" && message[2] === "CODE") {
const code = message[3];
if (code === this.auth) {
this.authenticatedConnections.add(sock);
this.send(sock, ["CONTROL", "AUTH", "SUCCESS"]);
} else {
this.send(sock, ["CONTROL", "AUTH", "INVALID", "Invalid Auth Code"]);
}
return true;
}
const handler = this.handlers.get(message[1]);
if (handler) {
return await handler.handleMessage(sock, message);
}
this.log("Failed to handle Control message", message);
return false;
}
send(sock: WebSocket | NodeJS.Process, response: ControlResponse) {
sock.send?.(JSON.stringify(response));
}
}

View File

@@ -1,45 +0,0 @@
import { WebSocket } from "ws";
import {
DecryptionCacheMessage,
DecryptionCacheResponse,
} from "@satellite-earth/core/types/control-api/decryption-cache.js";
import type App from "../../app/index.js";
import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'DECRYPTION-CACHE', ...] messages */
export default class DecryptionCacheActions implements ControlMessageHandler {
app: App;
name = "DECRYPTION-CACHE";
constructor(app: App) {
this.app = app;
}
handleMessage(sock: WebSocket | NodeJS.Process, message: DecryptionCacheMessage) {
const method = message[2];
switch (method) {
case "ADD-CONTENT":
this.app.decryptionCache.addEventContent(message[3], message[4]);
return true;
case "CLEAR":
this.app.decryptionCache.clearAll();
return true;
case "REQUEST":
const contents = this.app.decryptionCache.getEventsContent(message[3]);
for (const { event, content } of contents)
this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "CONTENT", event, content]);
this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "END"]);
return true;
default:
return false;
}
}
send(sock: WebSocket | NodeJS.Process, response: DecryptionCacheResponse) {
sock.send?.(JSON.stringify(response));
}
}

View File

@@ -1,31 +0,0 @@
import { WebSocket } from "ws";
import { DirectMessageMessage } from "@satellite-earth/core/types/control-api/direct-messages.js";
import type App from "../../app/index.js";
import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'DM', ...] messages */
export default class DirectMessageActions implements ControlMessageHandler {
app: App;
name = "DM";
constructor(app: App) {
this.app = app;
}
handleMessage(sock: WebSocket | NodeJS.Process, message: DirectMessageMessage) {
const method = message[2];
switch (method) {
case "OPEN":
this.app.directMessageManager.openConversation(message[3], message[4]);
return true;
case "CLOSE":
this.app.directMessageManager.closeConversation(message[3], message[4]);
return true;
default:
return false;
}
}
}

View File

@@ -1,27 +0,0 @@
import { WebSocket } from "ws";
import { LogsMessage } from "@satellite-earth/core/types/control-api/logs.js";
import type App from "../../app/index.js";
import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'DM', ...] messages */
export default class LogsActions implements ControlMessageHandler {
app: App;
name = "LOGS";
constructor(app: App) {
this.app = app;
}
handleMessage(sock: WebSocket | NodeJS.Process, message: LogsMessage) {
const method = message[2];
switch (method) {
case "CLEAR":
this.app.logStore.clearLogs(message[3] ? { service: message[3] } : undefined);
return true;
default:
return false;
}
}
}

View File

@@ -1,43 +0,0 @@
import { WebSocket } from "ws";
import { NotificationsMessage, NotificationsResponse } from "@satellite-earth/core/types/control-api/notifications.js";
import { ControlMessageHandler } from "./control-api.js";
import type App from "../../app/index.js";
import { NostrEvent } from "nostr-tools";
export default class NotificationActions implements ControlMessageHandler {
app: App;
name = "NOTIFICATIONS";
constructor(app: App) {
this.app = app;
}
handleMessage(sock: WebSocket | NodeJS.Process, message: NotificationsMessage): boolean {
const action = message[2];
switch (action) {
case "GET-VAPID-KEY":
this.send(sock, ["CONTROL", "NOTIFICATIONS", "VAPID-KEY", this.app.notifications.webPushKeys.publicKey]);
return true;
case "REGISTER":
this.app.notifications.addOrUpdateChannel(message[3]);
return true;
case "NOTIFY":
const event: NostrEvent | undefined = this.app.eventStore.getEventsForFilters([{ ids: [message[3]] }])?.[0];
if (event) this.app.notifications.notify(event);
return true;
case "UNREGISTER":
this.app.notifications.removeChannel(message[3]);
return true;
default:
return false;
}
}
send(sock: WebSocket | NodeJS.Process, response: NotificationsResponse) {
sock.send?.(JSON.stringify(response));
}
}

View File

@@ -1,69 +0,0 @@
import { WebSocket } from "ws";
import { verifyEvent } from "nostr-tools";
import { RemoteAuthMessage, RemoteAuthResponse } from "@satellite-earth/core/types/control-api/remote-auth.js";
import type App from "../../app/index.js";
import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'REMOTE-AUTH', ...] messages */
export default class RemoteAuthActions implements ControlMessageHandler {
app: App;
name = "REMOTE-AUTH";
private subscribed = new Set<WebSocket | NodeJS.Process>();
constructor(app: App) {
this.app = app;
// when config changes send it to the subscribed sockets
this.app.pool.emitter.on("challenge", (relay, challenge) => {
for (const sock of this.subscribed) {
this.send(sock, [
"CONTROL",
"REMOTE-AUTH",
"STATUS",
relay.url,
challenge,
!!this.app.pool.authenticated.get(relay.url),
]);
}
});
}
sendAllStatuses(sock: WebSocket | NodeJS.Process) {
for (const [url, relay] of this.app.pool) {
const challenge = this.app.pool.challenges.get(url);
const authenticated = this.app.pool.isAuthenticated(url);
if (challenge) {
this.send(sock, ["CONTROL", "REMOTE-AUTH", "STATUS", url, challenge, authenticated]);
}
}
}
async handleMessage(sock: WebSocket | NodeJS.Process, message: RemoteAuthMessage) {
const method = message[2];
switch (method) {
case "SUBSCRIBE":
this.subscribed.add(sock);
sock.once("close", () => this.subscribed.delete(sock));
this.sendAllStatuses(sock);
return true;
case "UNSUBSCRIBE":
this.subscribed.delete(sock);
return true;
case "AUTHENTICATE":
const event = message[3];
if (verifyEvent(event)) {
const relay = event.tags.find((t) => (t[0] = "relay"))?.[1];
if (relay) await this.app.pool.authenticate(relay, event);
}
default:
return false;
}
}
send(sock: WebSocket | NodeJS.Process, response: RemoteAuthResponse) {
sock.send?.(JSON.stringify(response));
}
}

View File

@@ -1,14 +1,14 @@
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { getProfileContent } from "applesauce-core/helpers"; import { getProfileContent } from "applesauce-core/helpers";
import { kinds } from "nostr-tools"; import { Filter, kinds } from "nostr-tools";
import z from "zod"; import z from "zod";
import mcpServer from "../server.js";
import { ownerFactory, ownerPublish } from "../../owner-signer.js";
import bakeryConfig from "../../bakery-config.js"; import bakeryConfig from "../../bakery-config.js";
import eventCache from "../../event-cache.js"; import eventCache from "../../event-cache.js";
import { normalizeToHexPubkey } from "../../../helpers/nip19.js";
import { asyncLoader } from "../../loaders.js"; import { asyncLoader } from "../../loaders.js";
import { ownerFactory, ownerPublish } from "../../owner-signer.js";
import { eventInput, userInput } from "../inputs.js";
import mcpServer from "../server.js";
mcpServer.tool( mcpServer.tool(
"sign_draft_event", "sign_draft_event",
@@ -70,9 +70,17 @@ mcpServer.tool(
mcpServer.tool( mcpServer.tool(
"search_events", "search_events",
"Search for events using a sqlite FTS5 search query", "Search for events using a sqlite FTS5 search query",
{ query: z.string(), kind: z.number().default(1), limit: z.number().default(50) }, {
async ({ query, kind, limit }) => { query: z.string().describe("The sqlite FTS5 search query"),
const events = await eventCache.getEventsForFilters([{ kinds: [kind], limit, search: query }]); kind: z.number().default(1).describe("The kind of events to search for"),
limit: z.number().default(50).describe("The number of events to return"),
author: userInput.optional().describe("The author of the events to search for"),
},
async ({ query, kind, limit, author }) => {
const filter: Filter = { kinds: [kind], limit, search: query };
if (author) filter.authors = [author];
const events = await eventCache.getEventsForFilters([filter]);
return { return {
content: events.map((event) => ({ type: "text", text: JSON.stringify(event) })), content: events.map((event) => ({ type: "text", text: JSON.stringify(event) })),
@@ -81,9 +89,7 @@ mcpServer.tool(
); );
// TODO: this needs to accept naddr, and nevent // TODO: this needs to accept naddr, and nevent
mcpServer.tool("get_event", "Get an event by id", { id: z.string().length(64) }, async ({ id }) => { mcpServer.tool("get_event_json", "Gets the full event as json", { event: eventInput }, async ({ event }) => {
const event = await eventCache.getEventsForFilters([{ ids: [id] }]);
return { return {
content: [{ type: "text", text: JSON.stringify(event) }], content: [{ type: "text", text: JSON.stringify(event) }],
}; };
@@ -92,7 +98,10 @@ mcpServer.tool("get_event", "Get an event by id", { id: z.string().length(64) },
mcpServer.tool( mcpServer.tool(
"search_users", "search_users",
"Search for users using a sqlite FTS5 search query", "Search for users using a sqlite FTS5 search query",
{ query: z.string(), limit: z.number().default(20) }, {
query: z.string().describe("The sqlite FTS5 search query"),
limit: z.number().default(20).describe("The number of users to return"),
},
async ({ query, limit }) => { async ({ query, limit }) => {
const profiles = await eventCache.getEventsForFilters([{ search: query, kinds: [kinds.Metadata], limit }]); const profiles = await eventCache.getEventsForFilters([{ search: query, kinds: [kinds.Metadata], limit }]);
@@ -123,17 +132,14 @@ mcpServer.tool(
"get_users_recent_events", "get_users_recent_events",
"Gets a list of recent events created by a pubkey", "Gets a list of recent events created by a pubkey",
{ {
pubkey: z user: userInput.describe("The user to get events for"),
.string()
.transform((hex) => normalizeToHexPubkey(hex, true))
.describe("The pubkey of the user to get events for"),
limit: z.number().default(10).describe("The number of events to return"), limit: z.number().default(10).describe("The number of events to return"),
kinds: z.array(z.number()).default([kinds.ShortTextNote]).describe("The kind number of events to return"), kinds: z.array(z.number()).default([kinds.ShortTextNote]).describe("The kind number of events to return"),
since: z.number().optional().describe("The unix timestamp to start the search from"), since: z.number().optional().describe("The unix timestamp to start the search from"),
until: z.number().optional().describe("The unix timestamp to end the search at"), until: z.number().optional().describe("The unix timestamp to end the search at"),
}, },
async ({ pubkey, limit, kinds, since, until }) => { async ({ user, limit, kinds, since, until }) => {
const events = await eventCache.getEventsForFilters([{ authors: [pubkey], limit, kinds, since, until }]); const events = await eventCache.getEventsForFilters([{ authors: [user], limit, kinds, since, until }]);
return { return {
content: events.map((event) => ({ type: "text", text: JSON.stringify(event) })), content: events.map((event) => ({ type: "text", text: JSON.stringify(event) })),
@@ -142,20 +148,17 @@ mcpServer.tool(
); );
mcpServer.tool( mcpServer.tool(
"get_events_pubkey_mentioned", "get_events_user_mentioned",
"Gets a list of recent events that the pubkey was mentioned in", "Gets a list of recent events that the user is mentioned in",
{ {
pubkey: z user: userInput.describe("The user who is mentioned in the events"),
.string()
.transform((hex) => normalizeToHexPubkey(hex, true))
.describe("The pubkey of the user to get events for"),
limit: z.number().default(10).describe("The number of events to return"), limit: z.number().default(10).describe("The number of events to return"),
kinds: z.array(z.number()).default([kinds.ShortTextNote]).describe("The kind number of events to return"), kinds: z.array(z.number()).default([kinds.ShortTextNote]).describe("The kind number of events to return"),
since: z.number().optional().describe("The unix timestamp to start the search from"), since: z.number().optional().describe("The unix timestamp to start the search from"),
until: z.number().optional().describe("The unix timestamp to end the search at"), until: z.number().optional().describe("The unix timestamp to end the search at"),
}, },
async ({ pubkey, limit, kinds, since, until }) => { async ({ user, limit, kinds, since, until }) => {
const events = await eventCache.getEventsForFilters([{ "#p": [pubkey], limit, kinds, since, until }]); const events = await eventCache.getEventsForFilters([{ "#p": [user], limit, kinds, since, until }]);
return { return {
content: events.map((event) => ({ type: "text", text: JSON.stringify(event) })), content: events.map((event) => ({ type: "text", text: JSON.stringify(event) })),

787
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff