From 1754e25daa4aa0ae29b4852e5b54848fe245976f Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Sun, 3 May 2026 18:42:22 +0200 Subject: [PATCH 1/7] first nao mcp version --- .../migrations-postgres/0038_mcp_endpoint.sql | 75 + .../meta/0038_snapshot.json | 3676 +++++++++++++++++ .../migrations-postgres/meta/_journal.json | 7 + .../migrations-sqlite/0038_mcp_endpoint.sql | 96 + .../migrations-sqlite/meta/0038_snapshot.json | 3508 ++++++++++++++++ .../migrations-sqlite/meta/_journal.json | 7 + apps/backend/package.json | 1 + apps/backend/src/agents/tools/index.ts | 22 +- apps/backend/src/agents/tools/story.ts | 2 +- apps/backend/src/app.ts | 33 +- apps/backend/src/auth.ts | 13 + apps/backend/src/db/abstractSchema.ts | 3 + apps/backend/src/db/pg-schema.ts | 111 +- apps/backend/src/db/sqlite-schema.ts | 119 +- apps/backend/src/mcp/auth.ts | 24 + apps/backend/src/mcp/logging.ts | 68 + apps/backend/src/mcp/routes.ts | 93 + apps/backend/src/mcp/server.ts | 58 + apps/backend/src/mcp/tools/agent.ts | 121 + apps/backend/src/mcp/tools/data.ts | 111 + apps/backend/src/mcp/tools/files.ts | 87 + apps/backend/src/mcp/tools/stories.ts | 316 ++ .../src/queries/mcp-endpoint.queries.ts | 52 + .../src/queries/shared-story.queries.ts | 2 +- apps/backend/src/queries/story.queries.ts | 528 ++- apps/backend/src/routes/auth.ts | 12 +- apps/backend/src/services/agent.ts | 88 +- apps/backend/src/services/live-story.ts | 4 +- apps/backend/src/trpc/chat-fork.routes.ts | 50 +- apps/backend/src/trpc/mcp-endpoint.routes.ts | 36 + apps/backend/src/trpc/router.ts | 2 + apps/backend/src/trpc/shared-story.routes.ts | 13 +- apps/backend/src/trpc/story.routes.ts | 66 +- apps/backend/src/types/mcp-endpoint.ts | 13 + apps/backend/tsconfig.json | 1 - apps/frontend/src/components/auth-form.tsx | 6 +- .../src/components/settings-search-index.ts | 33 + .../src/components/settings/mcp-endpoint.tsx | 448 ++ .../src/components/sidebar-settings-nav.tsx | 4 + .../src/components/stories-groups.tsx | 96 +- .../src/components/story-download.tsx | 32 +- .../src/components/tool-calls/mcp.tsx | 78 +- apps/frontend/src/lib/auth-client.ts | 8 +- apps/frontend/src/lib/stories-page.ts | 54 +- apps/frontend/src/routeTree.gen.ts | 68 + .../_sidebar-layout.settings.mcp-endpoint.tsx | 21 + ...r-layout.settings.project.mcp-endpoint.tsx | 15 + .../routes/_sidebar-layout.stories.index.tsx | 45 +- ...sidebar-layout.stories.shared.$shareId.tsx | 26 +- ...bar-layout.stories.standalone.$storyId.tsx | 109 + apps/frontend/src/routes/login.tsx | 19 +- apps/frontend/vite.config.ts | 6 + package-lock.json | 1 + 53 files changed, 10229 insertions(+), 258 deletions(-) create mode 100644 apps/backend/migrations-postgres/0038_mcp_endpoint.sql create mode 100644 apps/backend/migrations-postgres/meta/0038_snapshot.json create mode 100644 apps/backend/migrations-sqlite/0038_mcp_endpoint.sql create mode 100644 apps/backend/migrations-sqlite/meta/0038_snapshot.json create mode 100644 apps/backend/src/mcp/auth.ts create mode 100644 apps/backend/src/mcp/logging.ts create mode 100644 apps/backend/src/mcp/routes.ts create mode 100644 apps/backend/src/mcp/server.ts create mode 100644 apps/backend/src/mcp/tools/agent.ts create mode 100644 apps/backend/src/mcp/tools/data.ts create mode 100644 apps/backend/src/mcp/tools/files.ts create mode 100644 apps/backend/src/mcp/tools/stories.ts create mode 100644 apps/backend/src/queries/mcp-endpoint.queries.ts create mode 100644 apps/backend/src/trpc/mcp-endpoint.routes.ts create mode 100644 apps/backend/src/types/mcp-endpoint.ts create mode 100644 apps/frontend/src/components/settings/mcp-endpoint.tsx create mode 100644 apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx create mode 100644 apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx create mode 100644 apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx diff --git a/apps/backend/migrations-postgres/0038_mcp_endpoint.sql b/apps/backend/migrations-postgres/0038_mcp_endpoint.sql new file mode 100644 index 000000000..1c4c60d43 --- /dev/null +++ b/apps/backend/migrations-postgres/0038_mcp_endpoint.sql @@ -0,0 +1,75 @@ +CREATE TABLE "mcp_call_log" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "user_id" text NOT NULL, + "tool_name" text NOT NULL, + "duration_ms" integer, + "success" boolean NOT NULL, + "tool_input" jsonb, + "tool_output" jsonb, + "called_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "oauth_access_token" ( + "id" text PRIMARY KEY NOT NULL, + "access_token" text NOT NULL, + "refresh_token" text NOT NULL, + "access_token_expires_at" timestamp NOT NULL, + "refresh_token_expires_at" timestamp NOT NULL, + "client_id" text NOT NULL, + "user_id" text, + "scopes" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "oauth_access_token_access_token_unique" UNIQUE("access_token"), + CONSTRAINT "oauth_access_token_refresh_token_unique" UNIQUE("refresh_token") +); +--> statement-breakpoint +CREATE TABLE "oauth_application" ( + "id" text PRIMARY KEY NOT NULL, + "name" text, + "icon" text, + "metadata" text, + "client_id" text NOT NULL, + "client_secret" text, + "redirect_urls" text NOT NULL, + "type" text NOT NULL, + "authentication_scheme" text, + "disabled" boolean DEFAULT false, + "user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "oauth_application_client_id_unique" UNIQUE("client_id") +); +--> statement-breakpoint +CREATE TABLE "oauth_consent" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "user_id" text NOT NULL, + "scopes" text NOT NULL, + "consent_given" boolean NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "story" ALTER COLUMN "chat_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "project" ADD COLUMN "mcp_endpoint_settings" jsonb;--> statement-breakpoint +ALTER TABLE "story" ADD COLUMN "project_id" text;--> statement-breakpoint +ALTER TABLE "story" ADD COLUMN "user_id" text;--> statement-breakpoint +ALTER TABLE "mcp_call_log" ADD CONSTRAINT "mcp_call_log_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mcp_call_log" ADD CONSTRAINT "mcp_call_log_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_client_id_oauth_application_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_application"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_application" ADD CONSTRAINT "oauth_application_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_client_id_oauth_application_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_application"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "mcp_call_log_projectId_idx" ON "mcp_call_log" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "mcp_call_log_calledAt_idx" ON "mcp_call_log" USING btree ("called_at");--> statement-breakpoint +CREATE INDEX "oauth_access_token_clientId_idx" ON "oauth_access_token" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauth_access_token_userId_idx" ON "oauth_access_token" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "oauth_application_userId_idx" ON "oauth_application" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "oauth_consent_clientId_idx" ON "oauth_consent" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauth_consent_userId_idx" ON "oauth_consent" USING btree ("user_id");--> statement-breakpoint +ALTER TABLE "story" ADD CONSTRAINT "story_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "story" ADD CONSTRAINT "story_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "story_userId_idx" ON "story" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/backend/migrations-postgres/meta/0038_snapshot.json b/apps/backend/migrations-postgres/meta/0038_snapshot.json new file mode 100644 index 000000000..ecbea087c --- /dev/null +++ b/apps/backend/migrations-postgres/meta/0038_snapshot.json @@ -0,0 +1,3676 @@ +{ + "id": "96465050-5ae4-41a6-a8e5-eeac821b6daf", + "prevId": "82faa6ea-95ce-444e-97f6-5649a163bd4d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_orgId_idx": { + "name": "api_key_orgId_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_org_id_organization_id_fk": { + "name": "api_key_org_id_organization_id_fk", + "tableFrom": "api_key", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_hash_unique": { + "name": "api_key_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Conversation'" + }, + "is_starred": { + "name": "is_starred", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "slack_thread_id": { + "name": "slack_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teams_thread_id": { + "name": "teams_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "whatsapp_thread_id": { + "name": "whatsapp_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fork_metadata": { + "name": "fork_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_userId_idx": { + "name": "chat_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_projectId_idx": { + "name": "chat_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_slack_thread_idx": { + "name": "chat_slack_thread_idx", + "columns": [ + { + "expression": "slack_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_teams_thread_idx": { + "name": "chat_teams_thread_idx", + "columns": [ + { + "expression": "teams_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_telegram_thread_idx": { + "name": "chat_telegram_thread_idx", + "columns": [ + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_whatsapp_thread_idx": { + "name": "chat_whatsapp_thread_idx", + "columns": [ + { + "expression": "whatsapp_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_project_id_project_id_fk": { + "name": "chat_project_id_project_id_fk", + "tableFrom": "chat", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_message": { + "name": "chat_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "llm_model_id": { + "name": "llm_model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isForked": { + "name": "isForked", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "citation": { + "name": "citation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "input_total_tokens": { + "name": "input_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_no_cache_tokens": { + "name": "input_no_cache_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_cache_read_tokens": { + "name": "input_cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_cache_write_tokens": { + "name": "input_cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_total_tokens": { + "name": "output_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_text_tokens": { + "name": "output_text_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_reasoning_tokens": { + "name": "output_reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_message_chatId_idx": { + "name": "chat_message_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_message_createdAt_idx": { + "name": "chat_message_createdAt_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_message_chat_id_chat_id_fk": { + "name": "chat_message_chat_id_chat_id_fk", + "tableFrom": "chat_message", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_inference": { + "name": "llm_inference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_model_id": { + "name": "llm_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_total_tokens": { + "name": "input_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_no_cache_tokens": { + "name": "input_no_cache_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_cache_read_tokens": { + "name": "input_cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_cache_write_tokens": { + "name": "input_cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_total_tokens": { + "name": "output_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_text_tokens": { + "name": "output_text_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_reasoning_tokens": { + "name": "output_reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "llm_inference_projectId_idx": { + "name": "llm_inference_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "llm_inference_userId_idx": { + "name": "llm_inference_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "llm_inference_type_idx": { + "name": "llm_inference_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "llm_inference_project_id_project_id_fk": { + "name": "llm_inference_project_id_project_id_fk", + "tableFrom": "llm_inference", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "llm_inference_user_id_user_id_fk": { + "name": "llm_inference_user_id_user_id_fk", + "tableFrom": "llm_inference", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "llm_inference_chat_id_chat_id_fk": { + "name": "llm_inference_chat_id_chat_id_fk", + "tableFrom": "llm_inference", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_createdAt_idx": { + "name": "log_createdAt_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_level_idx": { + "name": "log_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_projectId_idx": { + "name": "log_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_project_id_project_id_fk": { + "name": "log_project_id_project_id_fk", + "tableFrom": "log", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_call_log": { + "name": "mcp_call_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "tool_input": { + "name": "tool_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tool_output": { + "name": "tool_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "called_at": { + "name": "called_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_call_log_projectId_idx": { + "name": "mcp_call_log_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_call_log_calledAt_idx": { + "name": "mcp_call_log_calledAt_idx", + "columns": [ + { + "expression": "called_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_call_log_project_id_project_id_fk": { + "name": "mcp_call_log_project_id_project_id_fk", + "tableFrom": "mcp_call_log", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_call_log_user_id_user_id_fk": { + "name": "mcp_call_log_user_id_user_id_fk", + "tableFrom": "mcp_call_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memories": { + "name": "memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_by": { + "name": "superseded_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memories_userId_idx": { + "name": "memories_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memories_chatId_idx": { + "name": "memories_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memories_supersededBy_idx": { + "name": "memories_supersededBy_idx", + "columns": [ + { + "expression": "superseded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memories_user_id_user_id_fk": { + "name": "memories_user_id_user_id_fk", + "tableFrom": "memories", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memories_chat_id_chat_id_fk": { + "name": "memories_chat_id_chat_id_fk", + "tableFrom": "memories", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_feedback": { + "name": "message_feedback", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "message_feedback_message_id_chat_message_id_fk": { + "name": "message_feedback_message_id_chat_message_id_fk", + "tableFrom": "message_feedback", + "tableTo": "chat_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_image": { + "name": "message_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_part": { + "name": "message_part", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_state": { + "name": "tool_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_error_text": { + "name": "tool_error_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_input": { + "name": "tool_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tool_raw_input": { + "name": "tool_raw_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tool_output": { + "name": "tool_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tool_approval_id": { + "name": "tool_approval_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_approval_approved": { + "name": "tool_approval_approved", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "tool_approval_reason": { + "name": "tool_approval_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_provider_metadata": { + "name": "tool_provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "parts_message_id_idx": { + "name": "parts_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "parts_message_id_order_idx": { + "name": "parts_message_id_order_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_part_message_id_chat_message_id_fk": { + "name": "message_part_message_id_chat_message_id_fk", + "tableFrom": "message_part", + "tableTo": "chat_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_part_image_id_message_image_id_fk": { + "name": "message_part_image_id_message_image_id_fk", + "tableFrom": "message_part", + "tableTo": "message_image", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "message_part_tool_call_id_unique": { + "name": "message_part_tool_call_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tool_call_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "text_required_if_type_is_text": { + "name": "text_required_if_type_is_text", + "value": "CASE WHEN \"message_part\".\"type\" = 'text' THEN \"message_part\".\"text\" IS NOT NULL ELSE TRUE END" + }, + "reasoning_text_required_if_type_is_reasoning": { + "name": "reasoning_text_required_if_type_is_reasoning", + "value": "CASE WHEN \"message_part\".\"type\" = 'reasoning' THEN \"message_part\".\"reasoning_text\" IS NOT NULL ELSE TRUE END" + }, + "tool_call_fields_required": { + "name": "tool_call_fields_required", + "value": "CASE WHEN \"message_part\".\"type\" LIKE 'tool-%' THEN \"message_part\".\"tool_call_id\" IS NOT NULL AND \"message_part\".\"tool_state\" IS NOT NULL ELSE TRUE END" + }, + "file_fields_required": { + "name": "file_fields_required", + "value": "CASE WHEN \"message_part\".\"type\" = 'file' THEN \"message_part\".\"media_type\" IS NOT NULL AND \"message_part\".\"image_id\" IS NOT NULL ELSE TRUE END" + } + }, + "isRLSEnabled": false + }, + "public.chart_image": { + "name": "chart_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chart_image_tool_call_id_unique": { + "name": "chart_image_tool_call_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tool_call_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_access_token_clientId_idx": { + "name": "oauth_access_token_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_userId_idx": { + "name": "oauth_access_token_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token" + ] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authentication_scheme": { + "name": "authentication_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_application_userId_idx": { + "name": "oauth_application_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_consent_clientId_idx": { + "name": "oauth_consent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_consent_userId_idx": { + "name": "oauth_consent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_userId_idx": { + "name": "org_member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_member_org_id_organization_id_fk": { + "name": "org_member_org_id_organization_id_fk", + "tableFrom": "org_member", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_client_id": { + "name": "google_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_client_secret": { + "name": "google_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_auth_domains": { + "name": "google_auth_domains", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_settings": { + "name": "agent_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled_tools": { + "name": "enabled_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "known_mcp_servers": { + "name": "known_mcp_servers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "env_vars": { + "name": "env_vars", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "slack_settings": { + "name": "slack_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "teams_settings": { + "name": "teams_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "telegram_settings": { + "name": "telegram_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "whatsapp_settings": { + "name": "whatsapp_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_endpoint_settings": { + "name": "mcp_endpoint_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_orgId_idx": { + "name": "project_orgId_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_org_id_organization_id_fk": { + "name": "project_org_id_organization_id_fk", + "tableFrom": "project", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "local_project_path_required": { + "name": "local_project_path_required", + "value": "CASE WHEN \"type\" = 'local' THEN \"path\" IS NOT NULL ELSE TRUE END" + } + }, + "isRLSEnabled": false + }, + "public.project_llm_config": { + "name": "project_llm_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled_models": { + "name": "enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_llm_config_projectId_idx": { + "name": "project_llm_config_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_llm_config_project_id_project_id_fk": { + "name": "project_llm_config_project_id_project_id_fk", + "tableFrom": "project_llm_config", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_llm_config_project_provider": { + "name": "project_llm_config_project_provider", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_member": { + "name": "project_member", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_userId_idx": { + "name": "project_member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_member_project_id_project_id_fk": { + "name": "project_member_project_id_project_id_fk", + "tableFrom": "project_member", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_member_user_id_user_id_fk": { + "name": "project_member_user_id_user_id_fk", + "tableFrom": "project_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_member_project_id_user_id_pk": { + "name": "project_member_project_id_user_id_pk", + "columns": [ + "project_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_provider_budget": { + "name": "project_provider_budget", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit_usd": { + "name": "limit_usd", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_provider_budget_projectId_idx": { + "name": "project_provider_budget_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_provider_budget_project_id_project_id_fk": { + "name": "project_provider_budget_project_id_project_id_fk", + "tableFrom": "project_provider_budget", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_provider_budget_project_provider": { + "name": "project_provider_budget_project_provider", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": { + "budget_period_valid": { + "name": "budget_period_valid", + "value": "\"project_provider_budget\".\"period\" IN ('day', 'week', 'month')" + } + }, + "isRLSEnabled": false + }, + "public.project_saved_prompt": { + "name": "project_saved_prompt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_saved_prompt_projectId_idx": { + "name": "project_saved_prompt_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_saved_prompt_project_id_project_id_fk": { + "name": "project_saved_prompt_project_id_project_id_fk", + "tableFrom": "project_saved_prompt", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_whatsapp_link": { + "name": "project_whatsapp_link", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whatsapp_user_id": { + "name": "whatsapp_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_whatsapp_link_userId_idx": { + "name": "project_whatsapp_link_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_whatsapp_link_project_id_project_id_fk": { + "name": "project_whatsapp_link_project_id_project_id_fk", + "tableFrom": "project_whatsapp_link", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_whatsapp_link_user_id_user_id_fk": { + "name": "project_whatsapp_link_user_id_user_id_fk", + "tableFrom": "project_whatsapp_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_whatsapp_link_project_id_whatsapp_user_id_pk": { + "name": "project_whatsapp_link_project_id_whatsapp_user_id_pk", + "columns": [ + "project_id", + "whatsapp_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_chat": { + "name": "shared_chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'project'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_chat_chat_id_chat_id_fk": { + "name": "shared_chat_chat_id_chat_id_fk", + "tableFrom": "shared_chat", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shared_chat_chatId_unique": { + "name": "shared_chat_chatId_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_chat_access": { + "name": "shared_chat_access", + "schema": "", + "columns": { + "shared_chat_id": { + "name": "shared_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_chat_access_shared_chat_id_shared_chat_id_fk": { + "name": "shared_chat_access_shared_chat_id_shared_chat_id_fk", + "tableFrom": "shared_chat_access", + "tableTo": "shared_chat", + "columnsFrom": [ + "shared_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_chat_access_user_id_user_id_fk": { + "name": "shared_chat_access_user_id_user_id_fk", + "tableFrom": "shared_chat_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_chat_access_shared_chat_id_user_id_pk": { + "name": "shared_chat_access_shared_chat_id_user_id_pk", + "columns": [ + "shared_chat_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_story": { + "name": "shared_story", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "story_id": { + "name": "story_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'project'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shared_story_projectId_idx": { + "name": "shared_story_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_story_storyId_idx": { + "name": "shared_story_storyId_idx", + "columns": [ + { + "expression": "story_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_story_story_id_story_id_fk": { + "name": "shared_story_story_id_story_id_fk", + "tableFrom": "shared_story", + "tableTo": "story", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_story_project_id_project_id_fk": { + "name": "shared_story_project_id_project_id_fk", + "tableFrom": "shared_story", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_story_user_id_user_id_fk": { + "name": "shared_story_user_id_user_id_fk", + "tableFrom": "shared_story", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_story_access": { + "name": "shared_story_access", + "schema": "", + "columns": { + "shared_story_id": { + "name": "shared_story_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_story_access_shared_story_id_shared_story_id_fk": { + "name": "shared_story_access_shared_story_id_shared_story_id_fk", + "tableFrom": "shared_story_access", + "tableTo": "shared_story", + "columnsFrom": [ + "shared_story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_story_access_user_id_user_id_fk": { + "name": "shared_story_access_user_id_user_id_fk", + "tableFrom": "shared_story_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_story_access_shared_story_id_user_id_pk": { + "name": "shared_story_access_shared_story_id_user_id_pk", + "columns": [ + "shared_story_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.story": { + "name": "story", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_live": { + "name": "is_live", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_live_text_dynamic": { + "name": "is_live_text_dynamic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cache_schedule": { + "name": "cache_schedule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_schedule_description": { + "name": "cache_schedule_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "story_chatId_idx": { + "name": "story_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "story_userId_idx": { + "name": "story_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "story_chat_id_chat_id_fk": { + "name": "story_chat_id_chat_id_fk", + "tableFrom": "story", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "story_project_id_project_id_fk": { + "name": "story_project_id_project_id_fk", + "tableFrom": "story", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "story_user_id_user_id_fk": { + "name": "story_user_id_user_id_fk", + "tableFrom": "story", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "story_chat_slug_unique": { + "name": "story_chat_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.story_data_cache": { + "name": "story_data_cache", + "schema": "", + "columns": { + "story_id": { + "name": "story_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "query_data": { + "name": "query_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "analysis_results": { + "name": "analysis_results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cached_at": { + "name": "cached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "story_data_cache_story_id_story_id_fk": { + "name": "story_data_cache_story_id_story_id_fk", + "tableFrom": "story_data_cache", + "tableTo": "story", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.story_version": { + "name": "story_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "story_id": { + "name": "story_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "story_version_storyId_idx": { + "name": "story_version_storyId_idx", + "columns": [ + { + "expression": "story_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "story_version_story_id_story_id_fk": { + "name": "story_version_story_id_story_id_fk", + "tableFrom": "story_version", + "tableTo": "story", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "story_version_story_version_unique": { + "name": "story_version_story_version_unique", + "nullsNotDistinct": false, + "columns": [ + "story_id", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requires_password_reset": { + "name": "requires_password_reset", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "memory_enabled": { + "name": "memory_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "messaging_provider_code": { + "name": "messaging_provider_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_access_token": { + "name": "github_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_messaging_provider_code_unique": { + "name": "user_messaging_provider_code_unique", + "nullsNotDistinct": false, + "columns": [ + "messaging_provider_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/migrations-postgres/meta/_journal.json b/apps/backend/migrations-postgres/meta/_journal.json index 9b9678dce..0daa3c876 100644 --- a/apps/backend/migrations-postgres/meta/_journal.json +++ b/apps/backend/migrations-postgres/meta/_journal.json @@ -267,6 +267,13 @@ "when": 1776332134291, "tag": "0037_add_citation_column", "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1777826483372, + "tag": "0038_mcp_endpoint", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql b/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql new file mode 100644 index 000000000..33213eb5f --- /dev/null +++ b/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql @@ -0,0 +1,96 @@ +CREATE TABLE `mcp_call_log` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `user_id` text NOT NULL, + `tool_name` text NOT NULL, + `duration_ms` integer, + `success` integer NOT NULL, + `tool_input` text, + `tool_output` text, + `called_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `mcp_call_log_projectId_idx` ON `mcp_call_log` (`project_id`);--> statement-breakpoint +CREATE INDEX `mcp_call_log_calledAt_idx` ON `mcp_call_log` (`called_at`);--> statement-breakpoint +CREATE TABLE `oauth_access_token` ( + `id` text PRIMARY KEY NOT NULL, + `access_token` text NOT NULL, + `refresh_token` text NOT NULL, + `access_token_expires_at` integer NOT NULL, + `refresh_token_expires_at` integer NOT NULL, + `client_id` text NOT NULL, + `user_id` text, + `scopes` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_application`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_access_token_access_token_unique` ON `oauth_access_token` (`access_token`);--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_access_token_refresh_token_unique` ON `oauth_access_token` (`refresh_token`);--> statement-breakpoint +CREATE INDEX `oauth_access_token_clientId_idx` ON `oauth_access_token` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_access_token_userId_idx` ON `oauth_access_token` (`user_id`);--> statement-breakpoint +CREATE TABLE `oauth_application` ( + `id` text PRIMARY KEY NOT NULL, + `name` text, + `icon` text, + `metadata` text, + `client_id` text NOT NULL, + `client_secret` text, + `redirect_urls` text NOT NULL, + `type` text NOT NULL, + `authentication_scheme` text, + `disabled` integer DEFAULT false, + `user_id` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_application_client_id_unique` ON `oauth_application` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_application_userId_idx` ON `oauth_application` (`user_id`);--> statement-breakpoint +CREATE TABLE `oauth_consent` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `user_id` text NOT NULL, + `scopes` text NOT NULL, + `consent_given` integer NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_application`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `oauth_consent_clientId_idx` ON `oauth_consent` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_consent_userId_idx` ON `oauth_consent` (`user_id`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_story` ( + `id` text PRIMARY KEY NOT NULL, + `chat_id` text, + `project_id` text, + `user_id` text, + `slug` text NOT NULL, + `title` text NOT NULL, + `is_live` integer DEFAULT false NOT NULL, + `is_live_text_dynamic` integer DEFAULT true NOT NULL, + `cache_schedule` text, + `cache_schedule_description` text, + `archived_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`chat_id`) REFERENCES `chat`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_story`("id", "chat_id", "project_id", "user_id", "slug", "title", "is_live", "is_live_text_dynamic", "cache_schedule", "cache_schedule_description", "archived_at", "created_at", "updated_at") SELECT "id", "chat_id", "project_id", "user_id", "slug", "title", "is_live", "is_live_text_dynamic", "cache_schedule", "cache_schedule_description", "archived_at", "created_at", "updated_at" FROM `story`;--> statement-breakpoint +DROP TABLE `story`;--> statement-breakpoint +ALTER TABLE `__new_story` RENAME TO `story`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `story_chatId_idx` ON `story` (`chat_id`);--> statement-breakpoint +CREATE INDEX `story_userId_idx` ON `story` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `story_chat_slug_unique` ON `story` (`chat_id`,`slug`);--> statement-breakpoint +ALTER TABLE `project` ADD `mcp_endpoint_settings` text; \ No newline at end of file diff --git a/apps/backend/migrations-sqlite/meta/0038_snapshot.json b/apps/backend/migrations-sqlite/meta/0038_snapshot.json new file mode 100644 index 000000000..ab485bd25 --- /dev/null +++ b/apps/backend/migrations-sqlite/meta/0038_snapshot.json @@ -0,0 +1,3508 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d9f4c3b0-4e14-4adb-8670-ef8e7b94c20a", + "prevId": "fd9181fb-f275-49cd-8546-6d7cb0e5c0be", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "api_key_key_hash_unique": { + "name": "api_key_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "api_key_orgId_idx": { + "name": "api_key_orgId_idx", + "columns": [ + "org_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_org_id_organization_id_fk": { + "name": "api_key_org_id_organization_id_fk", + "tableFrom": "api_key", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat": { + "name": "chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'New Conversation'" + }, + "is_starred": { + "name": "is_starred", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slack_thread_id": { + "name": "slack_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teams_thread_id": { + "name": "teams_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "whatsapp_thread_id": { + "name": "whatsapp_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fork_metadata": { + "name": "fork_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chat_userId_idx": { + "name": "chat_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "chat_projectId_idx": { + "name": "chat_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "chat_slack_thread_idx": { + "name": "chat_slack_thread_idx", + "columns": [ + "slack_thread_id" + ], + "isUnique": false + }, + "chat_teams_thread_idx": { + "name": "chat_teams_thread_idx", + "columns": [ + "teams_thread_id" + ], + "isUnique": false + }, + "chat_telegram_thread_idx": { + "name": "chat_telegram_thread_idx", + "columns": [ + "telegram_thread_id" + ], + "isUnique": false + }, + "chat_whatsapp_thread_idx": { + "name": "chat_whatsapp_thread_idx", + "columns": [ + "whatsapp_thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_project_id_project_id_fk": { + "name": "chat_project_id_project_id_fk", + "tableFrom": "chat", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_message": { + "name": "chat_message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llm_model_id": { + "name": "llm_model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isForked": { + "name": "isForked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "citation": { + "name": "citation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "input_total_tokens": { + "name": "input_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_no_cache_tokens": { + "name": "input_no_cache_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_cache_read_tokens": { + "name": "input_cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_cache_write_tokens": { + "name": "input_cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_total_tokens": { + "name": "output_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_text_tokens": { + "name": "output_text_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_reasoning_tokens": { + "name": "output_reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chat_message_chatId_idx": { + "name": "chat_message_chatId_idx", + "columns": [ + "chat_id" + ], + "isUnique": false + }, + "chat_message_createdAt_idx": { + "name": "chat_message_createdAt_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_message_chat_id_chat_id_fk": { + "name": "chat_message_chat_id_chat_id_fk", + "tableFrom": "chat_message", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "llm_inference": { + "name": "llm_inference", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llm_model_id": { + "name": "llm_model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_total_tokens": { + "name": "input_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_no_cache_tokens": { + "name": "input_no_cache_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_cache_read_tokens": { + "name": "input_cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_cache_write_tokens": { + "name": "input_cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_total_tokens": { + "name": "output_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_text_tokens": { + "name": "output_text_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_reasoning_tokens": { + "name": "output_reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "llm_inference_projectId_idx": { + "name": "llm_inference_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "llm_inference_userId_idx": { + "name": "llm_inference_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "llm_inference_type_idx": { + "name": "llm_inference_type_idx", + "columns": [ + "type" + ], + "isUnique": false + } + }, + "foreignKeys": { + "llm_inference_project_id_project_id_fk": { + "name": "llm_inference_project_id_project_id_fk", + "tableFrom": "llm_inference", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "llm_inference_user_id_user_id_fk": { + "name": "llm_inference_user_id_user_id_fk", + "tableFrom": "llm_inference", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "llm_inference_chat_id_chat_id_fk": { + "name": "llm_inference_chat_id_chat_id_fk", + "tableFrom": "llm_inference", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "log": { + "name": "log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "log_createdAt_idx": { + "name": "log_createdAt_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "log_level_idx": { + "name": "log_level_idx", + "columns": [ + "level" + ], + "isUnique": false + }, + "log_projectId_idx": { + "name": "log_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "log_project_id_project_id_fk": { + "name": "log_project_id_project_id_fk", + "tableFrom": "log", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_call_log": { + "name": "mcp_call_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_input": { + "name": "tool_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_output": { + "name": "tool_output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "called_at": { + "name": "called_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "mcp_call_log_projectId_idx": { + "name": "mcp_call_log_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "mcp_call_log_calledAt_idx": { + "name": "mcp_call_log_calledAt_idx", + "columns": [ + "called_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "mcp_call_log_project_id_project_id_fk": { + "name": "mcp_call_log_project_id_project_id_fk", + "tableFrom": "mcp_call_log", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_call_log_user_id_user_id_fk": { + "name": "mcp_call_log_user_id_user_id_fk", + "tableFrom": "mcp_call_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memories": { + "name": "memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "superseded_by": { + "name": "superseded_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "memories_userId_idx": { + "name": "memories_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "memories_chatId_idx": { + "name": "memories_chatId_idx", + "columns": [ + "chat_id" + ], + "isUnique": false + }, + "memories_supersededBy_idx": { + "name": "memories_supersededBy_idx", + "columns": [ + "superseded_by" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memories_user_id_user_id_fk": { + "name": "memories_user_id_user_id_fk", + "tableFrom": "memories", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memories_chat_id_chat_id_fk": { + "name": "memories_chat_id_chat_id_fk", + "tableFrom": "memories", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_feedback": { + "name": "message_feedback", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "message_feedback_message_id_chat_message_id_fk": { + "name": "message_feedback_message_id_chat_message_id_fk", + "tableFrom": "message_feedback", + "tableTo": "chat_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_image": { + "name": "message_image", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_part": { + "name": "message_part", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_state": { + "name": "tool_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_error_text": { + "name": "tool_error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_input": { + "name": "tool_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_raw_input": { + "name": "tool_raw_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_output": { + "name": "tool_output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_approval_id": { + "name": "tool_approval_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_approval_approved": { + "name": "tool_approval_approved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_approval_reason": { + "name": "tool_approval_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_provider_metadata": { + "name": "tool_provider_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_part_tool_call_id_unique": { + "name": "message_part_tool_call_id_unique", + "columns": [ + "tool_call_id" + ], + "isUnique": true + }, + "parts_message_id_idx": { + "name": "parts_message_id_idx", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "parts_message_id_order_idx": { + "name": "parts_message_id_order_idx", + "columns": [ + "message_id", + "order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "message_part_message_id_chat_message_id_fk": { + "name": "message_part_message_id_chat_message_id_fk", + "tableFrom": "message_part", + "tableTo": "chat_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_part_image_id_message_image_id_fk": { + "name": "message_part_image_id_message_image_id_fk", + "tableFrom": "message_part", + "tableTo": "message_image", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "text_required_if_type_is_text": { + "name": "text_required_if_type_is_text", + "value": "CASE WHEN type = 'text' THEN text IS NOT NULL ELSE TRUE END" + }, + "reasoning_text_required_if_type_is_reasoning": { + "name": "reasoning_text_required_if_type_is_reasoning", + "value": "CASE WHEN type = 'reasoning' THEN reasoning_text IS NOT NULL ELSE TRUE END" + }, + "tool_call_fields_required": { + "name": "tool_call_fields_required", + "value": "CASE WHEN type LIKE 'tool-%' THEN tool_call_id IS NOT NULL AND tool_state IS NOT NULL ELSE TRUE END" + }, + "file_fields_required": { + "name": "file_fields_required", + "value": "CASE WHEN type = 'file' THEN media_type IS NOT NULL ELSE TRUE END" + } + } + }, + "chart_image": { + "name": "chart_image", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chart_image_tool_call_id_unique": { + "name": "chart_image_tool_call_id_unique", + "columns": [ + "tool_call_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_token": { + "name": "oauth_access_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "columns": [ + "access_token" + ], + "isUnique": true + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "columns": [ + "refresh_token" + ], + "isUnique": true + }, + "oauth_access_token_clientId_idx": { + "name": "oauth_access_token_clientId_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_access_token_userId_idx": { + "name": "oauth_access_token_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_application": { + "name": "oauth_application", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authentication_scheme": { + "name": "authentication_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "oauth_application_userId_idx": { + "name": "oauth_application_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consent_given": { + "name": "consent_given", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "oauth_consent_clientId_idx": { + "name": "oauth_consent_clientId_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_consent_userId_idx": { + "name": "oauth_consent_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_member": { + "name": "org_member", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "org_member_userId_idx": { + "name": "org_member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "org_member_org_id_organization_id_fk": { + "name": "org_member_org_id_organization_id_fk", + "tableFrom": "org_member", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "columns": [ + "org_id", + "user_id" + ], + "name": "org_member_org_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "google_client_id": { + "name": "google_client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_client_secret": { + "name": "google_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_auth_domains": { + "name": "google_auth_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project": { + "name": "project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_settings": { + "name": "agent_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_tools": { + "name": "enabled_tools", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "known_mcp_servers": { + "name": "known_mcp_servers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "env_vars": { + "name": "env_vars", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "slack_settings": { + "name": "slack_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teams_settings": { + "name": "teams_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telegram_settings": { + "name": "telegram_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "whatsapp_settings": { + "name": "whatsapp_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_endpoint_settings": { + "name": "mcp_endpoint_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_orgId_idx": { + "name": "project_orgId_idx", + "columns": [ + "org_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_org_id_organization_id_fk": { + "name": "project_org_id_organization_id_fk", + "tableFrom": "project", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "local_project_path_required": { + "name": "local_project_path_required", + "value": "CASE WHEN \"type\" = 'local' THEN \"path\" IS NOT NULL ELSE TRUE END" + } + } + }, + "project_llm_config": { + "name": "project_llm_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_models": { + "name": "enabled_models", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_llm_config_projectId_idx": { + "name": "project_llm_config_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_llm_config_project_provider": { + "name": "project_llm_config_project_provider", + "columns": [ + "project_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_llm_config_project_id_project_id_fk": { + "name": "project_llm_config_project_id_project_id_fk", + "tableFrom": "project_llm_config", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_member": { + "name": "project_member", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_member_userId_idx": { + "name": "project_member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_member_project_id_project_id_fk": { + "name": "project_member_project_id_project_id_fk", + "tableFrom": "project_member", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_member_user_id_user_id_fk": { + "name": "project_member_user_id_user_id_fk", + "tableFrom": "project_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_member_project_id_user_id_pk": { + "columns": [ + "project_id", + "user_id" + ], + "name": "project_member_project_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_provider_budget": { + "name": "project_provider_budget", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "limit_usd": { + "name": "limit_usd", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "notified_at": { + "name": "notified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_provider_budget_projectId_idx": { + "name": "project_provider_budget_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_provider_budget_project_provider": { + "name": "project_provider_budget_project_provider", + "columns": [ + "project_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_provider_budget_project_id_project_id_fk": { + "name": "project_provider_budget_project_id_project_id_fk", + "tableFrom": "project_provider_budget", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "budget_period_valid": { + "name": "budget_period_valid", + "value": "period IN ('day', 'week', 'month')" + } + } + }, + "project_saved_prompt": { + "name": "project_saved_prompt", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_saved_prompt_projectId_idx": { + "name": "project_saved_prompt_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_saved_prompt_project_id_project_id_fk": { + "name": "project_saved_prompt_project_id_project_id_fk", + "tableFrom": "project_saved_prompt", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_whatsapp_link": { + "name": "project_whatsapp_link", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "whatsapp_user_id": { + "name": "whatsapp_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_whatsapp_link_userId_idx": { + "name": "project_whatsapp_link_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_whatsapp_link_project_id_project_id_fk": { + "name": "project_whatsapp_link_project_id_project_id_fk", + "tableFrom": "project_whatsapp_link", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_whatsapp_link_user_id_user_id_fk": { + "name": "project_whatsapp_link_user_id_user_id_fk", + "tableFrom": "project_whatsapp_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_whatsapp_link_project_id_whatsapp_user_id_pk": { + "columns": [ + "project_id", + "whatsapp_user_id" + ], + "name": "project_whatsapp_link_project_id_whatsapp_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_chat": { + "name": "shared_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "shared_chat_chatId_unique": { + "name": "shared_chat_chatId_unique", + "columns": [ + "chat_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_chat_chat_id_chat_id_fk": { + "name": "shared_chat_chat_id_chat_id_fk", + "tableFrom": "shared_chat", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_chat_access": { + "name": "shared_chat_access", + "columns": { + "shared_chat_id": { + "name": "shared_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "shared_chat_access_shared_chat_id_shared_chat_id_fk": { + "name": "shared_chat_access_shared_chat_id_shared_chat_id_fk", + "tableFrom": "shared_chat_access", + "tableTo": "shared_chat", + "columnsFrom": [ + "shared_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_chat_access_user_id_user_id_fk": { + "name": "shared_chat_access_user_id_user_id_fk", + "tableFrom": "shared_chat_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_chat_access_shared_chat_id_user_id_pk": { + "columns": [ + "shared_chat_id", + "user_id" + ], + "name": "shared_chat_access_shared_chat_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_story": { + "name": "shared_story", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "story_id": { + "name": "story_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "shared_story_projectId_idx": { + "name": "shared_story_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "shared_story_storyId_idx": { + "name": "shared_story_storyId_idx", + "columns": [ + "story_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_story_story_id_story_id_fk": { + "name": "shared_story_story_id_story_id_fk", + "tableFrom": "shared_story", + "tableTo": "story", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_story_project_id_project_id_fk": { + "name": "shared_story_project_id_project_id_fk", + "tableFrom": "shared_story", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_story_user_id_user_id_fk": { + "name": "shared_story_user_id_user_id_fk", + "tableFrom": "shared_story", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_story_access": { + "name": "shared_story_access", + "columns": { + "shared_story_id": { + "name": "shared_story_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "shared_story_access_shared_story_id_shared_story_id_fk": { + "name": "shared_story_access_shared_story_id_shared_story_id_fk", + "tableFrom": "shared_story_access", + "tableTo": "shared_story", + "columnsFrom": [ + "shared_story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_story_access_user_id_user_id_fk": { + "name": "shared_story_access_user_id_user_id_fk", + "tableFrom": "shared_story_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_story_access_shared_story_id_user_id_pk": { + "columns": [ + "shared_story_id", + "user_id" + ], + "name": "shared_story_access_shared_story_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "story": { + "name": "story", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_live": { + "name": "is_live", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_live_text_dynamic": { + "name": "is_live_text_dynamic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cache_schedule": { + "name": "cache_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_schedule_description": { + "name": "cache_schedule_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "story_chatId_idx": { + "name": "story_chatId_idx", + "columns": [ + "chat_id" + ], + "isUnique": false + }, + "story_userId_idx": { + "name": "story_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "story_chat_slug_unique": { + "name": "story_chat_slug_unique", + "columns": [ + "chat_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "story_chat_id_chat_id_fk": { + "name": "story_chat_id_chat_id_fk", + "tableFrom": "story", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "story_project_id_project_id_fk": { + "name": "story_project_id_project_id_fk", + "tableFrom": "story", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "story_user_id_user_id_fk": { + "name": "story_user_id_user_id_fk", + "tableFrom": "story", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "story_data_cache": { + "name": "story_data_cache", + "columns": { + "story_id": { + "name": "story_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "query_data": { + "name": "query_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "analysis_results": { + "name": "analysis_results", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_at": { + "name": "cached_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "story_data_cache_story_id_story_id_fk": { + "name": "story_data_cache_story_id_story_id_fk", + "tableFrom": "story_data_cache", + "tableTo": "story", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "story_version": { + "name": "story_version", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "story_id": { + "name": "story_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "story_version_storyId_idx": { + "name": "story_version_storyId_idx", + "columns": [ + "story_id" + ], + "isUnique": false + }, + "story_version_story_version_unique": { + "name": "story_version_story_version_unique", + "columns": [ + "story_id", + "version" + ], + "isUnique": true + } + }, + "foreignKeys": { + "story_version_story_id_story_id_fk": { + "name": "story_version_story_id_story_id_fk", + "tableFrom": "story_version", + "tableTo": "story", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requires_password_reset": { + "name": "requires_password_reset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "memory_enabled": { + "name": "memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "messaging_provider_code": { + "name": "messaging_provider_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_access_token": { + "name": "github_access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_messaging_provider_code_unique": { + "name": "user_messaging_provider_code_unique", + "columns": [ + "messaging_provider_code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/migrations-sqlite/meta/_journal.json b/apps/backend/migrations-sqlite/meta/_journal.json index 1070e212b..686189e0e 100644 --- a/apps/backend/migrations-sqlite/meta/_journal.json +++ b/apps/backend/migrations-sqlite/meta/_journal.json @@ -267,6 +267,13 @@ "when": 1776332018682, "tag": "0037_add_citation_column", "breakpoints": true + }, + { + "idx": 38, + "version": "6", + "when": 1777826473421, + "tag": "0038_mcp_endpoint", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 44d4800f0..f8e141bfe 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -60,6 +60,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^8.1.0", "@microsoft/microsoft-graph-client": "^3.0.7", + "@modelcontextprotocol/sdk": "^1.29.0", "@nao/shared": "*", "@openrouter/ai-sdk-provider": "^2.2.3", "@pydantic/monty": "^0.0.7", diff --git a/apps/backend/src/agents/tools/index.ts b/apps/backend/src/agents/tools/index.ts index 138914d64..e9f6a584d 100644 --- a/apps/backend/src/agents/tools/index.ts +++ b/apps/backend/src/agents/tools/index.ts @@ -29,16 +29,24 @@ export const tools = { suggest_follow_ups: suggestFollowUps, }; -export const getTools = (agentSettings: AgentSettings | null, extraTools?: Record) => { +export const getTools = ( + agentSettings: AgentSettings | null, + extraTools?: Record, + excludeTools?: readonly string[], +) => { const mcpTools = mcpService.getMcpTools(); - const { execute_python, execute_sandboxed_code, ...baseTools } = tools; + const include = (key: string) => !excludeTools?.includes(key); return { - ...baseTools, - ...mcpTools, - ...(agentSettings?.experimental?.pythonSandboxing && execute_python && { execute_python }), - ...(agentSettings?.experimental?.sandboxes && execute_sandboxed_code && { execute_sandboxed_code }), - ...extraTools, + ...Object.fromEntries(Object.entries(baseTools).filter(([k]) => include(k))), + ...Object.fromEntries(Object.entries(mcpTools).filter(([k]) => include(k))), + ...(agentSettings?.experimental?.pythonSandboxing && + execute_python && + include('execute_python') && { execute_python }), + ...(agentSettings?.experimental?.sandboxes && + execute_sandboxed_code && + include('execute_sandboxed_code') && { execute_sandboxed_code }), + ...Object.fromEntries(Object.entries(extraTools ?? {}).filter(([k]) => include(k))), }; }; diff --git a/apps/backend/src/agents/tools/story.ts b/apps/backend/src/agents/tools/story.ts index e7b9246b7..cdb95887d 100644 --- a/apps/backend/src/agents/tools/story.ts +++ b/apps/backend/src/agents/tools/story.ts @@ -61,7 +61,7 @@ export default createTool({ }; } - const existing = await storyQueries.getLatestVersion(chatId, input.id); + const existing = await storyQueries.getLatestVersionByChatAndSlug(chatId, input.id); if (!existing) { return fail(`Story "${input.id}" does not exist. Use "create" first.`); } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 2b403055d..deee53e7d 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -10,6 +10,7 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { env, isCloud } from './env'; +import { mcpServerRoutes } from './mcp/routes'; import { ensureOrganizationSetup } from './queries/organization.queries'; import { agentRoutes } from './routes/agent'; import { authRoutes } from './routes/auth'; @@ -172,6 +173,33 @@ app.register(githubRoutes, { prefix: '/api/github', }); +app.register(mcpServerRoutes, { + prefix: '/mcp', +}); + +async function proxyToBetterAuth(url: string, request: { headers: Record }) { + const auth = await (await import('./auth')).getAuth(); + const headers = (await import('./utils/utils')).convertHeaders(request.headers); + const req = new Request(url, { method: 'GET', headers }); + return auth.handler(req); +} + +app.get('/.well-known/oauth-protected-resource', async (request, reply) => { + const url = new URL('/api/auth/.well-known/oauth-protected-resource', `http://${request.headers.host}`); + const response = await proxyToBetterAuth(url.toString(), request); + reply.status(response.status); + response.headers.forEach((value, key) => reply.header(key, value)); + reply.send(await response.text()); +}); + +app.get('/.well-known/oauth-authorization-server', async (request, reply) => { + const url = new URL('/api/auth/.well-known/oauth-authorization-server', `http://${request.headers.host}`); + const response = await proxyToBetterAuth(url.toString(), request); + reply.status(response.status); + response.headers.forEach((value, key) => reply.header(key, value)); + reply.send(await response.text()); +}); + /** * Tests the API connection */ @@ -198,7 +226,10 @@ const isReservedBackendPath = (url: string) => { pathname === '/c' || pathname.startsWith('/c/') || pathname === '/i' || - pathname.startsWith('/i/') + pathname.startsWith('/i/') || + pathname === '/mcp' || + pathname.startsWith('/mcp/') || + pathname.startsWith('/.well-known/') ); }; diff --git a/apps/backend/src/auth.ts b/apps/backend/src/auth.ts index 083d6482f..92f3e0fcd 100644 --- a/apps/backend/src/auth.ts +++ b/apps/backend/src/auth.ts @@ -1,5 +1,7 @@ import { APIError, betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { mcp as mcpPlugin } from 'better-auth/plugins'; +import { bearer } from 'better-auth/plugins/bearer'; import { db } from './db/db'; import dbConfig, { Dialect } from './db/dbConfig'; @@ -71,6 +73,17 @@ async function createAuthInstance(googleConfig: GoogleConfig) { provider: dbConfig.dialect === Dialect.Postgres ? 'pg' : 'sqlite', schema: dbConfig.schema, }), + plugins: [ + bearer(), + mcpPlugin({ + loginPage: '/login', + oidcConfig: { + loginPage: '/login', + accessTokenExpiresIn: 86400, + refreshTokenExpiresIn: 604800, + }, + }), + ], trustedOrigins: env.BETTER_AUTH_URL ? [env.BETTER_AUTH_URL] : undefined, emailAndPassword: { enabled: true, diff --git a/apps/backend/src/db/abstractSchema.ts b/apps/backend/src/db/abstractSchema.ts index dfc03e235..6c6403da9 100644 --- a/apps/backend/src/db/abstractSchema.ts +++ b/apps/backend/src/db/abstractSchema.ts @@ -82,6 +82,9 @@ export type NewLlmInference = typeof sqliteSchema.llmInference.$inferInsert; export type DBLog = typeof sqliteSchema.log.$inferSelect; export type NewLog = typeof sqliteSchema.log.$inferInsert; +export type DBMcpCallLog = typeof sqliteSchema.mcpCallLog.$inferSelect; +export type NewMcpCallLog = typeof sqliteSchema.mcpCallLog.$inferInsert; + export type DBMessageImage = typeof sqliteSchema.messageImage.$inferSelect; export type NewMessageImage = typeof sqliteSchema.messageImage.$inferInsert; diff --git a/apps/backend/src/db/pg-schema.ts b/apps/backend/src/db/pg-schema.ts index 95e8a5fba..4b78c5364 100644 --- a/apps/backend/src/db/pg-schema.ts +++ b/apps/backend/src/db/pg-schema.ts @@ -19,6 +19,7 @@ import { AgentSettings } from '../types/agent-settings'; import { ForkMetadata, StopReason, ToolState, UIMessagePartType } from '../types/chat'; import { LLM_INFERENCE_TYPES } from '../types/llm'; import { LOG_LEVELS, LOG_SOURCES } from '../types/log'; +import { McpEndpointSettings } from '../types/mcp-endpoint'; import { MEMORY_CATEGORIES } from '../types/memory'; import { SlackSettings, TeamsSettings, TelegramSettings, WhatsappSettings } from '../types/messaging-provider'; import { ORG_ROLES } from '../types/organization'; @@ -152,6 +153,7 @@ export const project = pgTable( teamsSettings: jsonb('teams_settings').$type(), telegramSettings: jsonb('telegram_settings').$type(), whatsappSettings: jsonb('whatsapp_settings').$type(), + mcpEndpointSettings: jsonb('mcp_endpoint_settings').$type(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') @@ -492,9 +494,9 @@ export const story = pgTable( id: text('id') .$defaultFn(() => crypto.randomUUID()) .primaryKey(), - chatId: text('chat_id') - .notNull() - .references(() => chat.id, { onDelete: 'cascade' }), + chatId: text('chat_id').references(() => chat.id, { onDelete: 'cascade' }), + projectId: text('project_id').references(() => project.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), slug: text('slug').notNull(), title: text('title').notNull(), isLive: boolean('is_live').default(false).notNull(), @@ -508,7 +510,11 @@ export const story = pgTable( .$onUpdate(() => new Date()) .notNull(), }, - (t) => [unique('story_chat_slug_unique').on(t.chatId, t.slug), index('story_chatId_idx').on(t.chatId)], + (t) => [ + unique('story_chat_slug_unique').on(t.chatId, t.slug), + index('story_chatId_idx').on(t.chatId), + index('story_userId_idx').on(t.userId), + ], ); export const storyVersion = pgTable( @@ -662,3 +668,100 @@ export const log = pgTable( index('log_projectId_idx').on(t.projectId), ], ); + +export const mcpCallLog = pgTable( + 'mcp_call_log', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => project.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + toolName: text('tool_name').notNull(), + durationMs: integer('duration_ms'), + success: boolean('success').notNull(), + toolInput: jsonb('tool_input').$type(), + toolOutput: jsonb('tool_output').$type(), + calledAt: timestamp('called_at').defaultNow().notNull(), + }, + (t) => [index('mcp_call_log_projectId_idx').on(t.projectId), index('mcp_call_log_calledAt_idx').on(t.calledAt)], +); + +export const oauthApplication = pgTable( + 'oauth_application', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + name: text('name'), + icon: text('icon'), + metadata: text('metadata'), + clientId: text('client_id').notNull().unique(), + clientSecret: text('client_secret'), + redirectUrls: text('redirect_urls').notNull(), + type: text('type').notNull(), + authenticationScheme: text('authentication_scheme'), + disabled: boolean('disabled').default(false), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [index('oauth_application_userId_idx').on(t.userId)], +); + +export const oauthAccessToken = pgTable( + 'oauth_access_token', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + accessToken: text('access_token').notNull().unique(), + refreshToken: text('refresh_token').notNull().unique(), + accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(), + clientId: text('client_id') + .notNull() + .references(() => oauthApplication.clientId, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + scopes: text('scopes').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [ + index('oauth_access_token_clientId_idx').on(t.clientId), + index('oauth_access_token_userId_idx').on(t.userId), + ], +); + +export const oauthConsent = pgTable( + 'oauth_consent', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + clientId: text('client_id') + .notNull() + .references(() => oauthApplication.clientId, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + scopes: text('scopes').notNull(), + consentGiven: boolean('consent_given').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [index('oauth_consent_clientId_idx').on(t.clientId), index('oauth_consent_userId_idx').on(t.userId)], +); diff --git a/apps/backend/src/db/sqlite-schema.ts b/apps/backend/src/db/sqlite-schema.ts index ddf00d0c8..211e42979 100644 --- a/apps/backend/src/db/sqlite-schema.ts +++ b/apps/backend/src/db/sqlite-schema.ts @@ -8,6 +8,7 @@ import { AgentSettings } from '../types/agent-settings'; import { ForkMetadata, StopReason, ToolState, UIMessagePartType } from '../types/chat'; import { LLM_INFERENCE_TYPES } from '../types/llm'; import { LOG_LEVELS, LOG_SOURCES } from '../types/log'; +import { McpEndpointSettings } from '../types/mcp-endpoint'; import { MEMORY_CATEGORIES } from '../types/memory'; import { SlackSettings, TeamsSettings, TelegramSettings, WhatsappSettings } from '../types/messaging-provider'; import { ORG_ROLES } from '../types/organization'; @@ -157,6 +158,7 @@ export const project = sqliteTable( teamsSettings: text('teams_settings', { mode: 'json' }).$type(), telegramSettings: text('telegram_settings', { mode: 'json' }).$type(), whatsappSettings: text('whatsapp_settings', { mode: 'json' }).$type(), + mcpEndpointSettings: text('mcp_endpoint_settings', { mode: 'json' }).$type(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) @@ -518,9 +520,9 @@ export const story = sqliteTable( id: text('id') .$defaultFn(() => crypto.randomUUID()) .primaryKey(), - chatId: text('chat_id') - .notNull() - .references(() => chat.id, { onDelete: 'cascade' }), + chatId: text('chat_id').references(() => chat.id, { onDelete: 'cascade' }), + projectId: text('project_id').references(() => project.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), slug: text('slug').notNull(), title: text('title').notNull(), isLive: integer('is_live', { mode: 'boolean' }).default(false).notNull(), @@ -536,7 +538,11 @@ export const story = sqliteTable( .$onUpdate(() => new Date()) .notNull(), }, - (t) => [unique('story_chat_slug_unique').on(t.chatId, t.slug), index('story_chatId_idx').on(t.chatId)], + (t) => [ + unique('story_chat_slug_unique').on(t.chatId, t.slug), + index('story_chatId_idx').on(t.chatId), + index('story_userId_idx').on(t.userId), + ], ); export const storyVersion = sqliteTable( @@ -708,3 +714,108 @@ export const log = sqliteTable( index('log_projectId_idx').on(t.projectId), ], ); + +export const mcpCallLog = sqliteTable( + 'mcp_call_log', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => project.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + toolName: text('tool_name').notNull(), + durationMs: integer('duration_ms'), + success: integer('success', { mode: 'boolean' }).notNull(), + toolInput: text('tool_input', { mode: 'json' }).$type(), + toolOutput: text('tool_output', { mode: 'json' }).$type(), + calledAt: integer('called_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (t) => [index('mcp_call_log_projectId_idx').on(t.projectId), index('mcp_call_log_calledAt_idx').on(t.calledAt)], +); + +export const oauthApplication = sqliteTable( + 'oauth_application', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + name: text('name'), + icon: text('icon'), + metadata: text('metadata'), + clientId: text('client_id').notNull().unique(), + clientSecret: text('client_secret'), + redirectUrls: text('redirect_urls').notNull(), + type: text('type').notNull(), + authenticationScheme: text('authentication_scheme'), + disabled: integer('disabled', { mode: 'boolean' }).default(false), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [index('oauth_application_userId_idx').on(t.userId)], +); + +export const oauthAccessToken = sqliteTable( + 'oauth_access_token', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + accessToken: text('access_token').notNull().unique(), + refreshToken: text('refresh_token').notNull().unique(), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }).notNull(), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }).notNull(), + clientId: text('client_id') + .notNull() + .references(() => oauthApplication.clientId, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + scopes: text('scopes').notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [ + index('oauth_access_token_clientId_idx').on(t.clientId), + index('oauth_access_token_userId_idx').on(t.userId), + ], +); + +export const oauthConsent = sqliteTable( + 'oauth_consent', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + clientId: text('client_id') + .notNull() + .references(() => oauthApplication.clientId, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + scopes: text('scopes').notNull(), + consentGiven: integer('consent_given', { mode: 'boolean' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [index('oauth_consent_clientId_idx').on(t.clientId), index('oauth_consent_userId_idx').on(t.userId)], +); diff --git a/apps/backend/src/mcp/auth.ts b/apps/backend/src/mcp/auth.ts new file mode 100644 index 000000000..7f462626e --- /dev/null +++ b/apps/backend/src/mcp/auth.ts @@ -0,0 +1,24 @@ +import { getAuth } from '../auth'; +import { convertHeaders } from '../utils/utils'; + +export async function resolveUserId(fastifyRequest: { + headers: Record; + url: string; +}): Promise { + const auth = await getAuth(); + const headers = convertHeaders(fastifyRequest.headers); + + const session = await auth.api.getSession({ headers }); + if (session?.user) { + return session.user.id; + } + + const host = fastifyRequest.headers['host'] ?? 'localhost'; + const request = new Request(`http://${host}${fastifyRequest.url}`, { headers }); + const mcpSession = await auth.api.getMcpSession({ headers, request, asResponse: false }); + if (mcpSession?.userId) { + return mcpSession.userId; + } + + return null; +} diff --git a/apps/backend/src/mcp/logging.ts b/apps/backend/src/mcp/logging.ts new file mode 100644 index 000000000..02df728c4 --- /dev/null +++ b/apps/backend/src/mcp/logging.ts @@ -0,0 +1,68 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; + +import { insertMcpCallLog } from '../queries/mcp-endpoint.queries'; +import type { McpEndpointSettings } from '../types/mcp-endpoint'; + +export interface McpContext { + userId: string; + projectId: string; + settings: McpEndpointSettings; +} + +export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }; + +export type ToolResult = { + content: ToolContent[]; + isError?: boolean; + toolOutput?: unknown; +}; + +export type ToolExtra = RequestHandlerExtra; +export type ToolHandler = (args: T, extra: ToolExtra) => Promise; + +export const TOOL_MODE_MAP: Record = { + ask_nao: 'agentModeEnabled', + execute_sql: 'toolsModeEnabled', + grep: 'toolsModeEnabled', + ls: 'toolsModeEnabled', + list_stories: 'objectsModeEnabled', + get_story: 'objectsModeEnabled', + create_story: 'objectsModeEnabled', + update_story: 'objectsModeEnabled', + archive_story: 'objectsModeEnabled', + delete_story: 'objectsModeEnabled', +}; + +export function withLogging(toolName: string, ctx: McpContext, handler: ToolHandler): ToolHandler { + return async (args: T, extra: ToolExtra) => { + const modeKey = TOOL_MODE_MAP[toolName]; + if (modeKey && !ctx.settings[modeKey]) { + return { + content: [{ type: 'text' as const, text: 'This MCP mode is disabled by your admin.' }], + isError: true, + }; + } + + const start = Date.now(); + let success = true; + let result: ToolResult | undefined; + try { + result = await handler(args, extra); + return result; + } catch (error) { + success = false; + throw error; + } finally { + insertMcpCallLog({ + projectId: ctx.projectId, + userId: ctx.userId, + toolName, + durationMs: Date.now() - start, + success, + toolInput: args as unknown, + toolOutput: result?.toolOutput, + }).catch(() => {}); + } + }; +} diff --git a/apps/backend/src/mcp/routes.ts b/apps/backend/src/mcp/routes.ts new file mode 100644 index 000000000..fa7d62594 --- /dev/null +++ b/apps/backend/src/mcp/routes.ts @@ -0,0 +1,93 @@ +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import type { App } from '../app'; +import { getMcpEndpointSettings } from '../queries/mcp-endpoint.queries'; +import { resolveUserId } from './auth'; +import { createMcpServer, resolveProjectId, sessions } from './server'; + +function replyUnauthorized(request: FastifyRequest, reply: FastifyReply) { + const origin = `${request.protocol}://${request.headers.host ?? request.hostname}`; + const wwwAuth = `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`; + return reply + .status(401) + .header('WWW-Authenticate', wwwAuth) + .header('Access-Control-Expose-Headers', 'WWW-Authenticate') + .send({ error: 'Unauthorized. Provide a valid Bearer token in the Authorization header.' }); +} + +export const mcpServerRoutes = async (app: App) => { + app.post('/', async (request, reply) => { + const userId = await resolveUserId(request); + if (!userId) { + return replyUnauthorized(request, reply); + } + + const existingSessionId = request.headers['mcp-session-id'] as string | undefined; + if (existingSessionId) { + const session = sessions.get(existingSessionId); + if (session) { + session.lastAccess = Date.now(); + await session.transport.handleRequest(request.raw, reply.raw, request.body as Record); + reply.hijack(); + return; + } + return reply.status(404).send({ error: 'Session not found or expired. Please reinitialize.' }); + } + + const projectId = await resolveProjectId(userId); + const settings = await getMcpEndpointSettings(projectId); + if (!settings.enabled) { + return reply.status(503).send({ error: 'MCP is disabled for this workspace.' }); + } + + const server = createMcpServer(userId, projectId, settings); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, { transport, server, userId, lastAccess: Date.now() }); + }, + onsessionclosed: (sessionId) => { + sessions.delete(sessionId); + server.close().catch(() => {}); + }, + }); + + await server.connect(transport); + await transport.handleRequest(request.raw, reply.raw, request.body as Record); + reply.hijack(); + }); + + app.get('/', async (request, reply) => { + const userId = await resolveUserId(request); + if (!userId) { + return replyUnauthorized(request, reply); + } + + const sessionId = request.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + return reply.status(400).send({ error: 'Missing Mcp-Session-Id header for SSE connection.' }); + } + + const session = sessions.get(sessionId); + if (!session) { + return reply.status(404).send({ error: 'Session not found or expired.' }); + } + + session.lastAccess = Date.now(); + await session.transport.handleRequest(request.raw, reply.raw); + reply.hijack(); + }); + + app.delete('/', async (request, reply) => { + const sessionId = request.headers['mcp-session-id'] as string | undefined; + const session = sessionId ? sessions.get(sessionId) : undefined; + if (!session) { + return reply.status(400).send({ error: 'Invalid or missing session.' }); + } + + await session.transport.handleRequest(request.raw, reply.raw); + reply.hijack(); + }); +}; diff --git a/apps/backend/src/mcp/server.ts b/apps/backend/src/mcp/server.ts new file mode 100644 index 000000000..7aef7813f --- /dev/null +++ b/apps/backend/src/mcp/server.ts @@ -0,0 +1,58 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +import { listUserProjects } from '../queries/project.queries'; +import type { McpEndpointSettings } from '../types/mcp-endpoint'; +import { registerAgentTools } from './tools/agent'; +import { registerDataTools } from './tools/data'; +import { registerFileTools } from './tools/files'; +import { registerStoryTools } from './tools/stories'; + +export interface McpSession { + transport: StreamableHTTPServerTransport; + server: McpServer; + userId: string; + lastAccess: number; +} + +export const sessions = new Map(); + +const SESSION_TTL_MS = 30 * 60 * 1000; + +setInterval( + () => { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastAccess > SESSION_TTL_MS) { + session.server.close().catch(() => {}); + sessions.delete(id); + } + } + }, + 5 * 60 * 1000, +).unref(); + +export async function resolveProjectId(userId: string): Promise { + const projects = await listUserProjects(userId); + if (projects.length === 0) { + throw new Error('No projects found for this user. Create or join a project first.'); + } + if (projects.length === 1) { + return projects[0].id; + } + + const listing = projects.map((p) => ` - ${p.name} (${p.id})`).join('\n'); + throw new Error(`MCP only supports single-project workspaces. Multiple projects found for this user:\n${listing}`); +} + +export function createMcpServer(userId: string, projectId: string, settings: McpEndpointSettings): McpServer { + const server = new McpServer({ name: 'nao', version: '0.1.0' }, { capabilities: { tools: {} } }); + const ctx = { userId, projectId, settings }; + + registerAgentTools(server, ctx); + registerDataTools(server, ctx); + registerFileTools(server, ctx); + registerStoryTools(server, ctx); + + return server; +} diff --git a/apps/backend/src/mcp/tools/agent.ts b/apps/backend/src/mcp/tools/agent.ts new file mode 100644 index 000000000..71446eb17 --- /dev/null +++ b/apps/backend/src/mcp/tools/agent.ts @@ -0,0 +1,121 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import * as chatQueries from '../../queries/chat.queries'; +import type { AgentProgressEvent } from '../../services/agent'; +import { agentService } from '../../services/agent'; +import { mcpService } from '../../services/mcp'; +import { skillService } from '../../services/skill'; +import type { UIMessage } from '../../types/chat'; +import { logger } from '../../utils/logger'; +import type { McpContext, ToolExtra } from '../logging'; +import { withLogging } from '../logging'; + +export function registerAgentTools(server: McpServer, ctx: McpContext): void { + server.registerTool( + 'ask_nao', + { + description: 'Ask nao an analytics question in natural language. Creates a chat that is visible in the UI.', + inputSchema: { + question: z.string().describe('The analytics question'), + conversation_id: z + .string() + .optional() + .describe( + 'Existing chat ID to continue a conversation. Omit to start a new chat. ' + + 'Reuse the ID returned by a previous ask_nao call from the same conversation.', + ), + }, + }, + withLogging('ask_nao', ctx, async ({ question, conversation_id }, extra) => { + try { + await mcpService.initializeMcpState(ctx.projectId); + await skillService.initializeSkills(ctx.projectId); + + const { chat, uiMessages } = await buildChatContext( + ctx.projectId, + ctx.userId, + question, + conversation_id, + ); + + const agent = await agentService.create(chat, undefined, ['story', 'suggest_follow_ups']); + + const progressToken = extra._meta?.progressToken; + const result = await agent.streamWithProgress(uiMessages, makeProgressHandler(extra, progressToken)); + + await chatQueries.upsertMessage({ + chatId: chat.id, + role: 'assistant', + parts: result.responseParts, + tokenUsage: result.usage, + }); + + return { + content: [ + { type: 'text' as const, text: result.text }, + { type: 'text' as const, text: `\n\n[conversation_id: ${chat.id}]` }, + ], + toolOutput: { chatId: chat.id, text: result.text }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP ask_nao error: ${message}`, { + source: 'tool', + context: { question, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `Nao agent error: ${message}` }], isError: true }; + } + }), + ); +} + +async function buildChatContext( + projectId: string, + userId: string, + question: string, + conversationId: string | undefined, +): Promise<{ chat: { id: string; projectId: string; userId: string }; uiMessages: UIMessage[] }> { + const userMessage: UIMessage = { + id: crypto.randomUUID(), + role: 'user', + parts: [{ type: 'text', text: question }], + }; + + if (conversationId) { + const ownerId = await chatQueries.getChatOwnerId(conversationId); + if (ownerId === userId) { + const history = await chatQueries.getChatMessages(conversationId); + await chatQueries.upsertMessage({ ...userMessage, chatId: conversationId }); + return { + chat: { id: conversationId, projectId, userId }, + uiMessages: [...history, userMessage], + }; + } + } + + const chatId = crypto.randomUUID(); + await chatQueries.createChat({ id: chatId, projectId, userId, title: question.slice(0, 80) }, { text: question }); + return { + chat: { id: chatId, projectId, userId }, + uiMessages: [userMessage], + }; +} + +function makeProgressHandler(extra: ToolExtra, rawProgressToken: unknown) { + const progressToken = + typeof rawProgressToken === 'string' || typeof rawProgressToken === 'number' ? rawProgressToken : undefined; + + let chunkIndex = 0; + + return async (event: AgentProgressEvent) => { + if (progressToken === undefined || event.type !== 'tool-call') { + return; + } + + await extra.sendNotification({ + method: 'notifications/progress', + params: { progressToken, progress: ++chunkIndex, message: `[${event.toolName}]` }, + }); + }; +} diff --git a/apps/backend/src/mcp/tools/data.ts b/apps/backend/src/mcp/tools/data.ts new file mode 100644 index 000000000..a088095c2 --- /dev/null +++ b/apps/backend/src/mcp/tools/data.ts @@ -0,0 +1,111 @@ +import crypto from 'node:crypto'; + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { executeQuery } from '../../agents/tools/execute-sql'; +import { getEnvVars, retrieveProjectById } from '../../queries/project.queries'; +import { logger } from '../../utils/logger'; +import type { McpContext } from '../logging'; +import { withLogging } from '../logging'; + +export function registerDataTools(server: McpServer, ctx: McpContext): void { + server.registerTool( + 'execute_sql', + { + title: 'Execute SQL', + description: + 'Run a SQL query against the connected data warehouse. Returns rows as JSON. The response includes a `query_id` — pass it to `build_chart` or reference it in story `` blocks. Use ask_nao instead if you want Nao to write the SQL for you.', + inputSchema: { + sql: z.string().describe('The SQL query to execute'), + limit: z.number().optional().default(100).describe('Max rows to return (default 100, max 1000)'), + }, + }, + withLogging('execute_sql', ctx, async ({ sql, limit }) => { + try { + const project = await retrieveProjectById(ctx.projectId); + const envVars = await getEnvVars(ctx.projectId); + const cappedLimit = Math.min(limit, 1000); + + const result = await executeQuery( + { sql_query: sql }, + { + projectFolder: project.path!, + chatId: '', + agentSettings: null, + envVars, + queryResults: new Map(), + }, + ); + + const rows = result.data.slice(0, cappedLimit); + const queryId = `query_${crypto.randomUUID().slice(0, 8)}`; + const output = { query_id: queryId, columns: result.columns, row_count: rows.length, data: rows }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(output) }], + toolOutput: output, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP execute_sql error: ${message}`, { + source: 'tool', + context: { sql, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `SQL execution error: ${message}` }], isError: true }; + } + }), + ); + + server.registerTool( + 'build_chart', + { + title: 'Build Chart', + description: + 'Generate a Nao-compatible `` block to embed in story content. Always use this tool instead of writing `` blocks manually — it ensures the correct syntax for the Nao UI renderer. Workflow: execute_sql → build_chart → create_story/update_story (pass the returned block in `content` and the SQL rows in `query_data`).', + inputSchema: { + query_id: z.string().describe('The query_id returned by execute_sql.'), + chart_type: z + .enum(['bar', 'stacked_bar', 'line', 'area', 'stacked_area', 'pie', 'kpi_card', 'scatter', 'radar']) + .describe('Type of chart to render.'), + x_axis_key: z.string().describe('Column name for the X-axis / category labels.'), + x_axis_type: z + .enum(['date', 'number', 'category']) + .nullable() + .describe( + 'Use "date" for YYYY-MM-DD values, "category" for labels/periods, "number" for numeric axes. Use null if unsure.', + ), + series: z + .array( + z.object({ + data_key: z.string().describe('Column name from SQL result to plot.'), + color: z.string().optional().describe('CSS color (defaults to theme colors).'), + label: z.string().optional().describe('Label to display in the legend.'), + }), + ) + .min(1) + .describe('Columns to plot. Each entry needs at least a data_key (column name from SQL result).'), + title: z + .string() + .describe('Concise descriptive chart title. Do not include the chart type in the title.'), + }, + }, + withLogging('build_chart', ctx, async ({ query_id, chart_type, x_axis_key, x_axis_type, series, title }) => { + const typedSeries = series as Array<{ data_key: string; color?: string; label?: string }>; + const block = buildChartBlock(query_id, chart_type, x_axis_key, x_axis_type, typedSeries, title); + return { content: [{ type: 'text' as const, text: block }], toolOutput: { block } }; + }), + ); +} + +function buildChartBlock( + queryId: string, + chartType: string, + xAxisKey: string, + xAxisType: string | null, + series: Array<{ data_key: string; color?: string; label?: string }>, + title: string, +): string { + const escapedTitle = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const xAxisTypeAttr = xAxisType ? ` x_axis_type="${xAxisType}"` : ''; + return ``; +} diff --git a/apps/backend/src/mcp/tools/files.ts b/apps/backend/src/mcp/tools/files.ts new file mode 100644 index 000000000..beaea0279 --- /dev/null +++ b/apps/backend/src/mcp/tools/files.ts @@ -0,0 +1,87 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import grepTool from '../../agents/tools/grep'; +import listTool from '../../agents/tools/list'; +import { retrieveProjectById } from '../../queries/project.queries'; +import { logger } from '../../utils/logger'; +import type { McpContext } from '../logging'; +import { withLogging } from '../logging'; + +function makeExecutionOptions(projectFolder: string) { + const context = { projectFolder, chatId: '', agentSettings: null, envVars: {}, queryResults: new Map() }; + return { toolCallId: '', messages: [] as [], experimental_context: context }; +} + +export function registerFileTools(server: McpServer, ctx: McpContext): void { + server.registerTool( + 'grep', + { + title: 'Search Files', + description: + 'Search for text patterns in project files using regex. Respects .gitignore and .naoignore. Returns matching lines with file paths and line numbers.', + inputSchema: { + pattern: z.string().describe('The regex pattern to search for in file contents.'), + path: z.string().optional().describe('File or directory path to search in. Defaults to project root.'), + glob: z.string().optional().describe('Glob pattern to filter files (e.g. "*.ts", "*.{js,jsx}").'), + case_insensitive: z.boolean().optional().describe('Case insensitive search. Defaults to false.'), + context_lines: z.number().optional().describe('Lines of context to show around each match.'), + max_results: z.number().optional().describe('Maximum matches to return (default 100, max 500).'), + }, + }, + withLogging( + 'grep', + ctx, + async ({ pattern, path: searchPath, glob, case_insensitive, context_lines, max_results }) => { + try { + const project = await retrieveProjectById(ctx.projectId); + const result = await grepTool.execute!( + { + pattern, + path: searchPath, + glob, + case_insensitive, + context_lines, + max_results: Math.min(max_results ?? 100, 500), + }, + makeExecutionOptions(project.path!), + ); + return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], toolOutput: result }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP grep error: ${message}`, { + source: 'tool', + context: { pattern, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `Search error: ${message}` }], isError: true }; + } + }, + ), + ); + + server.registerTool( + 'ls', + { + title: 'List Files', + description: + 'List files and directories at the specified path within the project. Returns entry names, types, and sizes.', + inputSchema: { + path: z.string().optional().describe('Directory path to list. Defaults to project root ("/").'), + }, + }, + withLogging('ls', ctx, async ({ path: filePath }) => { + try { + const project = await retrieveProjectById(ctx.projectId); + const result = await listTool.execute!({ path: filePath ?? '/' }, makeExecutionOptions(project.path!)); + return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], toolOutput: result }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP ls error: ${message}`, { + source: 'tool', + context: { path: filePath, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `List error: ${message}` }], isError: true }; + } + }), + ); +} diff --git a/apps/backend/src/mcp/tools/stories.ts b/apps/backend/src/mcp/tools/stories.ts new file mode 100644 index 000000000..60b95f051 --- /dev/null +++ b/apps/backend/src/mcp/tools/stories.ts @@ -0,0 +1,316 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import type { UserStoryRow } from '../../queries/story.queries'; +import * as storyQueries from '../../queries/story.queries'; +import { logger } from '../../utils/logger'; +import type { McpContext } from '../logging'; +import { withLogging } from '../logging'; + +export function registerStoryTools(server: McpServer, ctx: McpContext): void { + server.registerTool( + 'list_stories', + { + title: 'List Stories', + description: 'List analytics stories (dashboards/reports) in the current project.', + inputSchema: { + limit: z.number().optional().default(20).describe('Max stories to return (default 20, max 100)'), + archived: z.boolean().optional().default(false).describe('Include archived stories'), + }, + }, + withLogging('list_stories', ctx, async ({ limit, archived }) => { + try { + const stories = await storyQueries.listAllUserStoriesInProject(ctx.userId, ctx.projectId, { + archived, + limit, + }); + const result = stories.map(({ id, title, createdAt, updatedAt, archivedAt }) => ({ + id, + title, + createdAt, + updatedAt, + archived: archivedAt !== null, + })); + return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], toolOutput: result }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP list_stories error: ${message}`, { source: 'tool', context: { userId: ctx.userId } }); + return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true }; + } + }), + ); + + server.registerTool( + 'get_story', + { + title: 'Get Story', + description: 'Retrieve a full story including its latest content/code.', + inputSchema: { + story_id: z.string().describe('The story ID to retrieve'), + }, + }, + withLogging('get_story', ctx, async ({ story_id }) => { + try { + const story = await resolveStory(story_id, ctx); + const version = await fetchLatestVersion(story); + + const output = { + id: story.id, + title: story.title, + slug: story.slug, + chatId: story.chatId, + projectId: story.projectId, + code: version?.code ?? null, + version: version?.version ?? null, + isLive: story.isLive, + archived: story.archivedAt !== null, + createdAt: story.createdAt, + updatedAt: story.updatedAt, + }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(output) }], + toolOutput: output, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP get_story error: ${message}`, { + source: 'tool', + context: { story_id, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true }; + } + }), + ); + + server.registerTool( + 'create_story', + { + title: 'Create Story', + description: + 'Create a new analytics story. Stories are markdown documents with embedded chart/table components rendered by the Nao UI.\n\nWorkflow for stories with charts:\n1. execute_sql → get rows + query_id\n2. build_chart → get a `` block string\n3. create_story → embed the block in `content`; pass SQL rows in `query_data`\n\nSupported blocks:\n- Charts: use `build_chart` to generate the correct `` block — do NOT write these manually.\n- Tables: `
`\n- Grids: `...blocks...` (1–4 columns)\n\nOmit `content` to create an empty story.', + inputSchema: { + title: z.string().describe('Story title'), + content: z + .string() + .optional() + .describe( + 'Story content (Nao story markdown with ,
, blocks). Omit to create empty.', + ), + query_data: z + .record( + z.string(), + z.object({ columns: z.array(z.string()), data: z.array(z.record(z.string(), z.unknown())) }), + ) + .optional() + .describe( + 'Query results keyed by query_id (query_id → { columns, data }). Required for stories with or
blocks so the Nao UI can render data.', + ), + }, + }, + withLogging('create_story', ctx, async ({ title, content, query_data }) => { + try { + const slug = generateSlug(title); + const code = content ?? `# ${title}\n`; + + const version = await storyQueries.createStandaloneVersion({ + userId: ctx.userId, + projectId: ctx.projectId, + slug, + title, + code, + action: 'create', + source: 'user', + }); + + const story = await storyQueries.getStandaloneStoryByUserAndSlug(ctx.userId, ctx.projectId, slug); + if (query_data && story) { + await storyQueries.upsertStoryDataCacheByStoryId( + story.id, + query_data as Record, + ); + } + const output = { id: story!.id, title: version.title, createdAt: story!.createdAt }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(output) }], + toolOutput: output, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP create_story error: ${message}`, { + source: 'tool', + context: { title, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true }; + } + }), + ); + + server.registerTool( + 'update_story', + { + title: 'Update Story', + description: + 'Update a story title and/or content. Omit fields to keep their current values.\n\nWhen adding or replacing charts, use `build_chart` first to generate the correct `` block, then pass it in `content`. Include the SQL rows for any new query_ids in `query_data`.', + inputSchema: { + story_id: z.string().describe('The story ID to update'), + title: z.string().optional().describe('New title (omit to keep current)'), + content: z.string().optional().describe('New full content (Nao story markdown). Omit to keep current.'), + query_data: z + .record( + z.string(), + z.object({ columns: z.array(z.string()), data: z.array(z.record(z.string(), z.unknown())) }), + ) + .optional() + .describe( + 'Query results keyed by query_id (query_id → { columns, data }). Required for any new or
blocks added in this update.', + ), + }, + }, + withLogging('update_story', ctx, async ({ story_id, title, content, query_data }) => { + try { + const story = await resolveStory(story_id, ctx); + const latestVersion = await fetchLatestVersion(story); + const newTitle = title ?? story.title; + const newCode = content ?? latestVersion?.code ?? `# ${newTitle}\n`; + const updated = await saveNewVersion(story, ctx, newTitle, newCode); + if (query_data && !story.chatId) { + const storyForCache = + (await storyQueries.getStandaloneStoryByUserAndSlug(ctx.userId, ctx.projectId, story.slug)) ?? + story; + const existingCache = await storyQueries.getStoryDataCacheByStoryId(storyForCache.id); + const mergedQueryData = { + ...((existingCache?.queryData as Record< + string, + { data: unknown[]; columns: string[] } + > | null) ?? {}), + ...query_data, + }; + await storyQueries.upsertStoryDataCacheByStoryId( + storyForCache.id, + mergedQueryData as Record, + ); + } + return { content: [{ type: 'text' as const, text: JSON.stringify(updated) }], toolOutput: updated }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP update_story error: ${message}`, { + source: 'tool', + context: { story_id, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true }; + } + }), + ); + + server.registerTool( + 'archive_story', + { + title: 'Archive Story', + description: 'Soft-delete a story by archiving it. The story can be restored later.', + inputSchema: { + story_id: z.string().describe('The story ID to archive'), + }, + }, + withLogging('archive_story', ctx, async ({ story_id }) => { + try { + const story = await resolveStory(story_id, ctx); + await storyQueries.archiveByStoryId(story.id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ id: story.id, archived: true }) }], + toolOutput: { id: story.id, archived: true }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP archive_story error: ${message}`, { + source: 'tool', + context: { story_id, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true }; + } + }), + ); + + server.registerTool( + 'delete_story', + { + title: 'Delete Story', + description: + 'Permanently delete a story and all its versions. This cannot be undone. Use archive_story if you want a recoverable soft-delete.', + inputSchema: { + story_id: z.string().describe('The story ID to permanently delete'), + }, + }, + withLogging('delete_story', ctx, async ({ story_id }) => { + try { + const story = await resolveStory(story_id, ctx); + await storyQueries.deleteStory(story.id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ id: story.id, deleted: true }) }], + toolOutput: { id: story.id, deleted: true }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`MCP delete_story error: ${message}`, { + source: 'tool', + context: { story_id, userId: ctx.userId }, + }); + return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true }; + } + }), + ); +} + +export function generateSlug(title: string): string { + return ( + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'untitled' + ); +} + +async function resolveStory(storyId: string, ctx: McpContext): Promise { + const story = await storyQueries.getStoryByIdForUser(storyId, ctx.userId); + if (!story) { + throw new Error(`Story not found: ${storyId}`); + } + return story; +} + +async function fetchLatestVersion(story: UserStoryRow) { + return story.chatId + ? storyQueries.getLatestVersionByChatAndSlug(story.chatId, story.slug) + : storyQueries.getLatestVersionByStoryId(story.id); +} + +async function saveNewVersion( + story: UserStoryRow, + ctx: McpContext, + title: string, + code: string, +): Promise<{ id: string; title: string; updatedAt: Date }> { + if (story.chatId) { + await storyQueries.createVersion({ + chatId: story.chatId, + slug: story.slug, + title, + code, + action: 'update', + source: 'user', + }); + const updated = await storyQueries.getStoryByChatAndSlug(story.chatId, story.slug); + return { id: updated!.id, title: updated!.title, updatedAt: updated!.updatedAt }; + } + + await storyQueries.createStandaloneVersion({ + userId: ctx.userId, + projectId: ctx.projectId, + slug: story.slug, + title, + code, + action: 'update', + source: 'user', + }); + const updated = await storyQueries.getStandaloneStoryByUserAndSlug(ctx.userId, ctx.projectId, story.slug); + return { id: updated!.id, title: updated!.title, updatedAt: updated!.updatedAt }; +} diff --git a/apps/backend/src/queries/mcp-endpoint.queries.ts b/apps/backend/src/queries/mcp-endpoint.queries.ts new file mode 100644 index 000000000..14d73686b --- /dev/null +++ b/apps/backend/src/queries/mcp-endpoint.queries.ts @@ -0,0 +1,52 @@ +import { desc, eq } from 'drizzle-orm'; + +import type { NewMcpCallLog } from '../db/abstractSchema'; +import s from '../db/abstractSchema'; +import { db } from '../db/db'; +import { DEFAULT_MCP_ENDPOINT_SETTINGS, type McpEndpointSettings } from '../types/mcp-endpoint'; + +export async function getMcpEndpointSettings(projectId: string): Promise { + const [row] = await db + .select({ mcpEndpointSettings: s.project.mcpEndpointSettings }) + .from(s.project) + .where(eq(s.project.id, projectId)) + .limit(1) + .execute(); + + return row?.mcpEndpointSettings ?? DEFAULT_MCP_ENDPOINT_SETTINGS; +} + +export async function updateMcpEndpointSettings( + projectId: string, + settings: Partial, +): Promise { + const current = await getMcpEndpointSettings(projectId); + const merged = { ...current, ...settings }; + + await db.update(s.project).set({ mcpEndpointSettings: merged }).where(eq(s.project.id, projectId)).execute(); + + return merged; +} + +export async function insertMcpCallLog(entry: Omit): Promise { + await db.insert(s.mcpCallLog).values(entry).execute(); +} + +export async function getRecentMcpCallLogs(projectId: string, limit = 50) { + return db + .select({ + id: s.mcpCallLog.id, + userId: s.mcpCallLog.userId, + userName: s.user.name, + toolName: s.mcpCallLog.toolName, + durationMs: s.mcpCallLog.durationMs, + success: s.mcpCallLog.success, + calledAt: s.mcpCallLog.calledAt, + }) + .from(s.mcpCallLog) + .leftJoin(s.user, eq(s.mcpCallLog.userId, s.user.id)) + .where(eq(s.mcpCallLog.projectId, projectId)) + .orderBy(desc(s.mcpCallLog.calledAt)) + .limit(limit) + .execute(); +} diff --git a/apps/backend/src/queries/shared-story.queries.ts b/apps/backend/src/queries/shared-story.queries.ts index 55617aaa8..515f33be2 100644 --- a/apps/backend/src/queries/shared-story.queries.ts +++ b/apps/backend/src/queries/shared-story.queries.ts @@ -5,7 +5,7 @@ import { db } from '../db/db'; export type SharedStoryWithLatest = DBSharedStory & { authorName: string; - chatId: string; + chatId: string | null; slug: string; title: string; code: string; diff --git a/apps/backend/src/queries/story.queries.ts b/apps/backend/src/queries/story.queries.ts index 526679f31..39727c9e9 100644 --- a/apps/backend/src/queries/story.queries.ts +++ b/apps/backend/src/queries/story.queries.ts @@ -1,8 +1,25 @@ -import { and, asc, desc, eq, isNull, max, or, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, isNull, max, or, type SQL, sql } from 'drizzle-orm'; import s, { type DBStory, type DBStoryDataCache, type DBStoryVersion } from '../db/abstractSchema'; import { db } from '../db/db'; +export type UserStoryRow = Pick< + DBStory, + | 'id' + | 'chatId' + | 'projectId' + | 'userId' + | 'slug' + | 'title' + | 'isLive' + | 'isLiveTextDynamic' + | 'cacheSchedule' + | 'cacheScheduleDescription' + | 'archivedAt' + | 'createdAt' + | 'updatedAt' +> & { code: string }; + export async function getStoryByChatAndSlug(chatId: string, slug: string): Promise { const [row] = await db .select() @@ -14,153 +31,96 @@ export async function getStoryByChatAndSlug(chatId: string, slug: string): Promi return row ?? null; } -export async function getOrCreateStory(data: { chatId: string; slug: string; title: string }): Promise { - const existing = await getStoryByChatAndSlug(data.chatId, data.slug); - if (existing) { - return existing; - } - - await db - .insert(s.story) - .values({ chatId: data.chatId, slug: data.slug, title: data.title }) - .onConflictDoNothing({ target: [s.story.chatId, s.story.slug] }) - .execute(); - - const row = await getStoryByChatAndSlug(data.chatId, data.slug); - if (!row) { - throw new Error(`Failed to create or retrieve story: ${data.chatId}/${data.slug}`); - } - return row; -} - -export async function createStoryVersion(data: { - chatId: string; - slug: string; - title: string; - code: string; - action: 'create' | 'update' | 'replace'; - source: 'assistant' | 'user'; -}): Promise { - const story = await getOrCreateStory({ - chatId: data.chatId, - slug: data.slug, - title: data.title, - }); - - if (story.title !== data.title) { - await db.update(s.story).set({ title: data.title }).where(eq(s.story.id, story.id)).execute(); - } - - const nextVersion = db - .select({ v: sql`coalesce(max(${s.storyVersion.version}), 0) + 1` }) - .from(s.storyVersion) - .where(eq(s.storyVersion.storyId, story.id)); - - const [created] = await db - .insert(s.storyVersion) - .values({ - storyId: story.id, - code: data.code, - action: data.action, - source: data.source, - version: sql`(${nextVersion})`, - }) - .returning() - .execute(); - - return { ...created, title: data.title }; -} +export async function getStoryByIdForUser(storyId: string, userId: string): Promise { + const latestVersions = latestVersionsSubquery(); -export async function getLatestVersion( - chatId: string, - slug: string, -): Promise< - | (DBStoryVersion & - Pick< - DBStory, - 'title' | 'isLive' | 'isLiveTextDynamic' | 'cacheSchedule' | 'cacheScheduleDescription' | 'archivedAt' - >) - | null -> { const [row] = await db .select({ - id: s.storyVersion.id, - storyId: s.storyVersion.storyId, - version: s.storyVersion.version, - code: s.storyVersion.code, - action: s.storyVersion.action, - source: s.storyVersion.source, - createdAt: s.storyVersion.createdAt, + id: s.story.id, + chatId: s.story.chatId, + projectId: s.story.projectId, + userId: s.story.userId, + slug: s.story.slug, title: s.story.title, isLive: s.story.isLive, isLiveTextDynamic: s.story.isLiveTextDynamic, cacheSchedule: s.story.cacheSchedule, cacheScheduleDescription: s.story.cacheScheduleDescription, archivedAt: s.story.archivedAt, + createdAt: s.story.createdAt, + updatedAt: s.story.updatedAt, + code: s.storyVersion.code, }) - .from(s.storyVersion) - .innerJoin(s.story, eq(s.storyVersion.storyId, s.story.id)) - .where(and(eq(s.story.chatId, chatId), eq(s.story.slug, slug))) - .orderBy(desc(s.storyVersion.version)) + .from(s.story) + .leftJoin(s.chat, eq(s.story.chatId, s.chat.id)) + .innerJoin(latestVersions, eq(s.story.id, latestVersions.storyId)) + .innerJoin( + s.storyVersion, + and(eq(s.storyVersion.storyId, s.story.id), eq(s.storyVersion.version, latestVersions.maxVersion)), + ) + .where( + and( + eq(s.story.id, storyId), + or(eq(s.chat.userId, userId), and(isNull(s.story.chatId), eq(s.story.userId, userId))), + ), + ) .limit(1) .execute(); return row ?? null; } -export async function getVersionByNumber( - chatId: string, +export async function getStandaloneStoryByUserAndSlug( + userId: string, + projectId: string, slug: string, - versionNumber: number, -): Promise< - | (DBStoryVersion & - Pick< - DBStory, - 'title' | 'isLive' | 'isLiveTextDynamic' | 'cacheSchedule' | 'cacheScheduleDescription' | 'archivedAt' - >) - | null -> { +): Promise { const [row] = await db - .select({ - id: s.storyVersion.id, - storyId: s.storyVersion.storyId, - version: s.storyVersion.version, - code: s.storyVersion.code, - action: s.storyVersion.action, - source: s.storyVersion.source, - createdAt: s.storyVersion.createdAt, - title: s.story.title, - isLive: s.story.isLive, - isLiveTextDynamic: s.story.isLiveTextDynamic, - cacheSchedule: s.story.cacheSchedule, - cacheScheduleDescription: s.story.cacheScheduleDescription, - archivedAt: s.story.archivedAt, - }) - .from(s.storyVersion) - .innerJoin(s.story, eq(s.storyVersion.storyId, s.story.id)) - .where(and(eq(s.story.chatId, chatId), eq(s.story.slug, slug), eq(s.storyVersion.version, versionNumber))) + .select() + .from(s.story) + .where( + and( + eq(s.story.projectId, projectId), + eq(s.story.userId, userId), + eq(s.story.slug, slug), + isNull(s.story.chatId), + ), + ) .limit(1) .execute(); return row ?? null; } -export async function listStoryVersions(chatId: string, slug: string): Promise { - return db - .select({ - id: s.storyVersion.id, - storyId: s.storyVersion.storyId, - version: s.storyVersion.version, - code: s.storyVersion.code, - action: s.storyVersion.action, - source: s.storyVersion.source, - createdAt: s.storyVersion.createdAt, - }) - .from(s.storyVersion) - .innerJoin(s.story, eq(s.storyVersion.storyId, s.story.id)) - .where(and(eq(s.story.chatId, chatId), eq(s.story.slug, slug))) - .orderBy(asc(s.storyVersion.version)) - .execute(); +export function listUserChatStories(userId: string, options?: { archived?: boolean }): Promise { + return queryStoriesWithLatestVersion(eq(s.chat.userId, userId), options); +} + +export function listUserStandaloneStories( + userId: string, + projectId: string, + options?: { archived?: boolean; limit?: number }, +): Promise { + const limit = Math.min(options?.limit ?? 50, 200); + return queryStoriesWithLatestVersion( + and(eq(s.story.projectId, projectId), eq(s.story.userId, userId), isNull(s.story.chatId))!, + { ...options, limit }, + ); +} + +export function listAllUserStoriesInProject( + userId: string, + projectId: string, + options?: { archived?: boolean; limit?: number }, +): Promise { + const limit = Math.min(options?.limit ?? 20, 100); + return queryStoriesWithLatestVersion( + or( + and(eq(s.chat.projectId, projectId), eq(s.chat.userId, userId)), + and(eq(s.story.projectId, projectId), eq(s.story.userId, userId), isNull(s.story.chatId)), + )!, + { ...options, limit }, + ); } export async function listStoriesInChat( @@ -185,39 +145,92 @@ export async function listStoriesInChat( })); } -export async function listUserStories( - userId: string, - options?: { archived?: boolean }, -): Promise<{ slug: string; chatId: string; title: string; code: string; createdAt: Date }[]> { - const latestVersions = db - .select({ - storyId: s.storyVersion.storyId, - maxVersion: max(s.storyVersion.version).as('max_version'), - }) +export async function createStoryVersion(data: { + chatId: string; + slug: string; + title: string; + code: string; + action: 'create' | 'update' | 'replace'; + source: 'assistant' | 'user'; +}): Promise { + const story = await getOrCreateStory({ chatId: data.chatId, slug: data.slug, title: data.title }); + + if (story.title !== data.title) { + await db.update(s.story).set({ title: data.title }).where(eq(s.story.id, story.id)).execute(); + } + + const nextVersion = db + .select({ v: sql`coalesce(max(${s.storyVersion.version}), 0) + 1` }) .from(s.storyVersion) - .groupBy(s.storyVersion.storyId) - .as('latest'); + .where(eq(s.storyVersion.storyId, story.id)); - const archivedFilter = options?.archived ? sql`${s.story.archivedAt} IS NOT NULL` : isNull(s.story.archivedAt); + const [created] = await db + .insert(s.storyVersion) + .values({ + storyId: story.id, + code: data.code, + action: data.action, + source: data.source, + version: sql`(${nextVersion})`, + }) + .returning() + .execute(); - return db - .select({ - slug: s.story.slug, - chatId: s.story.chatId, - title: s.story.title, - code: s.storyVersion.code, - createdAt: s.story.createdAt, + return { ...created, title: data.title }; +} + +export async function createStandaloneVersion(data: { + userId: string; + projectId: string; + slug: string; + title: string; + code: string; + action: 'create' | 'update' | 'replace'; + source: 'assistant' | 'user'; +}): Promise { + const existing = await getStandaloneStoryByUserAndSlug(data.userId, data.projectId, data.slug); + + let story: DBStory; + if (existing) { + story = existing; + if (story.title !== data.title) { + await db.update(s.story).set({ title: data.title }).where(eq(s.story.id, story.id)).execute(); + } + } else { + const [created] = await db + .insert(s.story) + .values({ projectId: data.projectId, userId: data.userId, slug: data.slug, title: data.title }) + .returning() + .execute(); + story = created; + } + + const nextVersion = db + .select({ v: sql`coalesce(max(${s.storyVersion.version}), 0) + 1` }) + .from(s.storyVersion) + .where(eq(s.storyVersion.storyId, story.id)); + + const [created] = await db + .insert(s.storyVersion) + .values({ + storyId: story.id, + code: data.code, + action: data.action, + source: data.source, + version: sql`(${nextVersion})`, }) - .from(s.story) - .innerJoin(s.chat, eq(s.story.chatId, s.chat.id)) - .innerJoin(latestVersions, eq(s.story.id, latestVersions.storyId)) - .innerJoin( - s.storyVersion, - and(eq(s.storyVersion.storyId, s.story.id), eq(s.storyVersion.version, latestVersions.maxVersion)), - ) - .where(and(eq(s.chat.userId, userId), archivedFilter)) - .orderBy(desc(s.story.createdAt)) + .returning() .execute(); + + return { ...created, title: data.title }; +} + +export async function deleteStory(storyId: string): Promise { + await db.delete(s.story).where(eq(s.story.id, storyId)).execute(); +} + +export async function assignChatToStory(storyId: string, chatId: string): Promise { + await db.update(s.story).set({ chatId }).where(eq(s.story.id, storyId)).execute(); } export async function archiveStory(chatId: string, slug: string): Promise { @@ -250,6 +263,14 @@ export async function unarchiveStory(chatId: string, slug: string): Promise { + await db.update(s.story).set({ archivedAt: new Date() }).where(eq(s.story.id, storyId)).execute(); +} + +export async function unarchiveByStoryId(storyId: string): Promise { + await db.update(s.story).set({ archivedAt: null }).where(eq(s.story.id, storyId)).execute(); +} + export async function updateStoryLiveSettings( chatId: string, slug: string, @@ -267,20 +288,67 @@ export async function updateStoryLiveSettings( .execute(); } -export async function getStoryDataCache(chatId: string, slug: string): Promise { - const [row] = await db +type StoryVersionWithStory = DBStoryVersion & + Pick< + DBStory, + 'title' | 'isLive' | 'isLiveTextDynamic' | 'cacheSchedule' | 'cacheScheduleDescription' | 'archivedAt' + >; + +export function getLatestVersionByChatAndSlug(chatId: string, slug: string): Promise { + return getStoryVersion(and(eq(s.story.chatId, chatId), eq(s.story.slug, slug))!, { latest: true }); +} + +export function getLatestVersionByStoryId(storyId: string): Promise { + return getStoryVersion(eq(s.story.id, storyId), { latest: true }); +} + +export function getVersionByNumber( + chatId: string, + slug: string, + versionNumber: number, +): Promise { + return getStoryVersion( + and(eq(s.story.chatId, chatId), eq(s.story.slug, slug), eq(s.storyVersion.version, versionNumber))!, + ); +} + +export async function listStoryVersions(chatId: string, slug: string): Promise { + return db .select({ - storyId: s.storyDataCache.storyId, - queryData: s.storyDataCache.queryData, - analysisResults: s.storyDataCache.analysisResults, - cachedAt: s.storyDataCache.cachedAt, + id: s.storyVersion.id, + storyId: s.storyVersion.storyId, + version: s.storyVersion.version, + code: s.storyVersion.code, + action: s.storyVersion.action, + source: s.storyVersion.source, + createdAt: s.storyVersion.createdAt, }) - .from(s.storyDataCache) - .innerJoin(s.story, eq(s.storyDataCache.storyId, s.story.id)) + .from(s.storyVersion) + .innerJoin(s.story, eq(s.storyVersion.storyId, s.story.id)) .where(and(eq(s.story.chatId, chatId), eq(s.story.slug, slug))) + .orderBy(asc(s.storyVersion.version)) .execute(); +} - return row ?? null; +export async function updateLatestVersionCode(chatId: string, slug: string, code: string): Promise { + const latest = await getLatestVersionByChatAndSlug(chatId, slug); + if (!latest) { + return; + } + + await db + .update(s.storyVersion) + .set({ code }) + .where(and(eq(s.storyVersion.storyId, latest.storyId), eq(s.storyVersion.version, latest.version))) + .execute(); +} + +export function getStoryDataCacheByChatAndSlug(chatId: string, slug: string): Promise { + return getStoryDataCache(and(eq(s.story.chatId, chatId), eq(s.story.slug, slug))!); +} + +export function getStoryDataCacheByStoryId(storyId: string): Promise { + return getStoryDataCache(eq(s.story.id, storyId)); } export async function upsertStoryDataCache( @@ -316,16 +384,17 @@ export async function upsertStoryDataCache( return row; } -export async function updateLatestVersionCode(chatId: string, slug: string, code: string): Promise { - const latest = await getLatestVersion(chatId, slug); - if (!latest) { - return; - } - +export async function upsertStoryDataCacheByStoryId( + storyId: string, + queryData: Record, +): Promise { await db - .update(s.storyVersion) - .set({ code }) - .where(and(eq(s.storyVersion.storyId, latest.storyId), eq(s.storyVersion.version, latest.version))) + .insert(s.storyDataCache) + .values({ storyId, queryData, cachedAt: new Date() }) + .onConflictDoUpdate({ + target: s.storyDataCache.storyId, + set: { queryData, cachedAt: new Date() }, + }) .execute(); } @@ -355,6 +424,64 @@ export async function getSqlQueryById( return result[queryId] ?? null; } +async function queryStoriesWithLatestVersion( + whereCondition: SQL, + options?: { archived?: boolean; limit?: number }, +): Promise { + const latestVersions = latestVersionsSubquery(); + + const query = db + .select({ + id: s.story.id, + chatId: s.story.chatId, + projectId: s.story.projectId, + userId: s.story.userId, + slug: s.story.slug, + title: s.story.title, + isLive: s.story.isLive, + isLiveTextDynamic: s.story.isLiveTextDynamic, + cacheSchedule: s.story.cacheSchedule, + cacheScheduleDescription: s.story.cacheScheduleDescription, + archivedAt: s.story.archivedAt, + createdAt: s.story.createdAt, + updatedAt: s.story.updatedAt, + code: s.storyVersion.code, + }) + .from(s.story) + .leftJoin(s.chat, eq(s.story.chatId, s.chat.id)) + .innerJoin(latestVersions, eq(s.story.id, latestVersions.storyId)) + .innerJoin( + s.storyVersion, + and(eq(s.storyVersion.storyId, s.story.id), eq(s.storyVersion.version, latestVersions.maxVersion)), + ) + .where(and(whereCondition, archivedStoryFilter(options?.archived))) + .orderBy(desc(s.story.createdAt)); + + if (options?.limit !== undefined) { + return query.limit(options.limit).execute(); + } + return query.execute(); +} + +async function getOrCreateStory(data: { chatId: string; slug: string; title: string }): Promise { + const existing = await getStoryByChatAndSlug(data.chatId, data.slug); + if (existing) { + return existing; + } + + await db + .insert(s.story) + .values({ chatId: data.chatId, slug: data.slug, title: data.title }) + .onConflictDoNothing({ target: [s.story.chatId, s.story.slug] }) + .execute(); + + const row = await getStoryByChatAndSlug(data.chatId, data.slug); + if (!row) { + throw new Error(`Failed to create or retrieve story: ${data.chatId}/${data.slug}`); + } + return row; +} + async function getSqlQueriesByIds( chatId: string, queryIds: Set, @@ -380,3 +507,64 @@ async function getSqlQueriesByIds( return queries; } + +async function getStoryVersion( + whereCondition: SQL, + options?: { latest?: boolean }, +): Promise { + const query = db + .select({ + id: s.storyVersion.id, + storyId: s.storyVersion.storyId, + version: s.storyVersion.version, + code: s.storyVersion.code, + action: s.storyVersion.action, + source: s.storyVersion.source, + createdAt: s.storyVersion.createdAt, + title: s.story.title, + isLive: s.story.isLive, + isLiveTextDynamic: s.story.isLiveTextDynamic, + cacheSchedule: s.story.cacheSchedule, + cacheScheduleDescription: s.story.cacheScheduleDescription, + archivedAt: s.story.archivedAt, + }) + .from(s.storyVersion) + .innerJoin(s.story, eq(s.storyVersion.storyId, s.story.id)) + .where(whereCondition); + + const ordered = options?.latest ? query.orderBy(desc(s.storyVersion.version)) : query; + const [row] = await ordered.limit(1).execute(); + + return row ?? null; +} + +async function getStoryDataCache(whereCondition: SQL): Promise { + const [row] = await db + .select({ + storyId: s.storyDataCache.storyId, + queryData: s.storyDataCache.queryData, + analysisResults: s.storyDataCache.analysisResults, + cachedAt: s.storyDataCache.cachedAt, + }) + .from(s.storyDataCache) + .innerJoin(s.story, eq(s.storyDataCache.storyId, s.story.id)) + .where(whereCondition) + .execute(); + + return row ?? null; +} + +function latestVersionsSubquery() { + return db + .select({ + storyId: s.storyVersion.storyId, + maxVersion: max(s.storyVersion.version).as('max_version'), + }) + .from(s.storyVersion) + .groupBy(s.storyVersion.storyId) + .as('latest'); +} + +function archivedStoryFilter(archived: boolean | undefined): SQL { + return archived ? sql`${s.story.archivedAt} IS NOT NULL` : isNull(s.story.archivedAt); +} diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 7674a02cc..fb504d9df 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -2,6 +2,16 @@ import { App } from '../app'; import { getAuth } from '../auth'; import { convertHeaders } from '../utils/utils'; +function serializeBody(body: unknown, contentType: string | undefined): string | undefined { + if (!body) { + return undefined; + } + if (contentType?.includes('application/x-www-form-urlencoded') && typeof body === 'object') { + return new URLSearchParams(body as Record).toString(); + } + return JSON.stringify(body); +} + export const authRoutes = async (app: App) => { app.route({ method: ['GET', 'POST'], @@ -16,7 +26,7 @@ export const authRoutes = async (app: App) => { const req = new Request(url.toString(), { method: request.method, headers, - body: request.body ? JSON.stringify(request.body) : undefined, + body: serializeBody(request.body, request.headers['content-type']), }); // Process authentication request const auth = await getAuth(); diff --git a/apps/backend/src/services/agent.ts b/apps/backend/src/services/agent.ts index 674c48b5d..ce57fd131 100644 --- a/apps/backend/src/services/agent.ts +++ b/apps/backend/src/services/agent.ts @@ -38,6 +38,7 @@ import { TokenCost, TokenUsage, UIMessage, + UIMessagePart, } from '../types/chat'; import { Provider } from '../types/messaging-provider'; import { ToolContext } from '../types/tools'; @@ -59,6 +60,10 @@ import { memoryService } from './memory'; import { getAzureAccessTokenForUser } from './microsoft-auth.service'; import { skillService } from './skill'; +export type AgentProgressEvent = + | { type: 'tool-call'; toolName: string; toolCallId: string } + | { type: 'tool-result'; toolName: string; toolCallId: string }; + export interface AgentRunResult { text: string; usage: TokenUsage; @@ -73,6 +78,8 @@ export interface AgentRunResult { toolCalls: ReadonlyArray<{ toolName: string; toolCallId: string; input: unknown }>; toolResults: ReadonlyArray<{ toolCallId: string; output?: unknown }>; }>; + /** All message parts (step-starts, tool calls, text) for persisting to the DB */ + responseParts: UIMessagePart[]; } export type AgentChat = Pick & { @@ -87,7 +94,11 @@ export class AgentService { await assertBudgetNotExceeded(projectId, resolved.provider); } - async create(chat: AgentChat, modelSelection?: LlmSelectedModel): Promise { + async create( + chat: AgentChat, + modelSelection?: LlmSelectedModel, + excludeTools?: readonly string[], + ): Promise { this._disposeAgent(chat.id); const resolvedLlmSelectedModel = await this._getResolvedLlmSelectedModel(chat.projectId, modelSelection); await assertBudgetNotExceeded(chat.projectId, resolvedLlmSelectedModel.provider); @@ -95,7 +106,7 @@ export class AgentService { const agentSettings = await projectQueries.getAgentSettings(chat.projectId); const toolContext = await this._getToolContext(chat.projectId, chat.id, chat.userId, agentSettings); const webTools = await this._resolveWebTools(chat.projectId, resolvedLlmSelectedModel.provider, agentSettings); - const agentTools = getTools(agentSettings, webTools ?? undefined); + const agentTools = getTools(agentSettings, webTools ?? undefined, excludeTools); const agent = new AgentManager( chat, modelConfig, @@ -407,10 +418,16 @@ class AgentManager { } try { - const latestVersions = new Map>>(); + const latestVersions = new Map< + string, + Awaited> + >(); await Promise.all( [...lastToolCallByStory.keys()].map(async (storyId) => { - latestVersions.set(storyId, await storyQueries.getLatestVersion(this.chat.id, storyId)); + latestVersions.set( + storyId, + await storyQueries.getLatestVersionByChatAndSlug(this.chat.id, storyId), + ); }), ); @@ -550,6 +567,69 @@ class AgentManager { durationMs, responseMessages: result.response.messages, steps: result.steps as AgentRunResult['steps'], + responseParts: [], + }; + } finally { + this._onDispose(); + } + } + + async streamWithProgress( + uiMessages: UIMessage[], + onEvent: (event: AgentProgressEvent) => Promise, + ): Promise { + const startTime = performance.now(); + const messages = await this._buildModelMessages(uiMessages); + const responseParts: UIMessagePart[] = []; + const pendingToolCalls = new Map(); + + try { + const result = await this._agent.stream({ + messages, + abortSignal: this._abortController.signal, + }); + + for await (const part of result.fullStream) { + if (part.type === 'tool-call') { + pendingToolCalls.set(part.toolCallId, { toolName: part.toolName, args: part.input }); + await onEvent({ type: 'tool-call', toolName: part.toolName, toolCallId: part.toolCallId }); + } else if (part.type === 'tool-result') { + const pending = pendingToolCalls.get(part.toolCallId); + if (pending) { + responseParts.push({ + type: `tool-${pending.toolName}`, + toolName: pending.toolName, + toolCallId: part.toolCallId, + state: 'output-available', + input: pending.args, + output: part.output, + } as unknown as UIMessagePart); + pendingToolCalls.delete(part.toolCallId); + } + await onEvent({ type: 'tool-result', toolName: part.toolName, toolCallId: part.toolCallId }); + } + } + + const text = await result.text; + if (text) { + responseParts.push({ type: 'text', text }); + } + + const durationMs = Math.round(performance.now() - startTime); + const usage = convertToTokenUsage(await result.totalUsage); + const cost = convertToCost(usage, this._modelSelection.provider, this._modelSelection.modelId); + const finishReason = (await result.finishReason) ?? 'stop'; + const steps = await result.steps; + + return { + text, + usage, + cost, + finishReason, + durationMs, + responseMessages: [], + steps: steps as AgentRunResult['steps'], + responseParts, }; } finally { this._onDispose(); diff --git a/apps/backend/src/services/live-story.ts b/apps/backend/src/services/live-story.ts index c7b2c89e3..de6e74e07 100644 --- a/apps/backend/src/services/live-story.ts +++ b/apps/backend/src/services/live-story.ts @@ -42,7 +42,7 @@ export interface RefreshResult { } export async function refreshStoryData(chatId: string, slug: string): Promise { - const version = await storyQueries.getLatestVersion(chatId, slug); + const version = await storyQueries.getLatestVersionByChatAndSlug(chatId, slug); if (!version) { throw new Error('Story not found'); } @@ -100,7 +100,7 @@ export async function getStoryQueryData( return { queryData: await getQueryDataFromCode(chatId, code), cachedAt: null }; } - const cache = await storyQueries.getStoryDataCache(chatId, slug); + const cache = await storyQueries.getStoryDataCacheByChatAndSlug(chatId, slug); if (cache && !isCacheExpired(cache.cachedAt, cacheSchedule)) { return { queryData: cache.queryData, cachedAt: cache.cachedAt }; diff --git a/apps/backend/src/trpc/chat-fork.routes.ts b/apps/backend/src/trpc/chat-fork.routes.ts index bb1a269ca..a24c3a69f 100644 --- a/apps/backend/src/trpc/chat-fork.routes.ts +++ b/apps/backend/src/trpc/chat-fork.routes.ts @@ -8,7 +8,7 @@ import * as sharedStoryQueries from '../queries/shared-story.queries'; import * as storyQueries from '../queries/story.queries'; import { compactionService } from '../services/compaction'; import type { ForkMetadata, UIMessage, UIMessagePart } from '../types/chat'; -import { canSendProcedure, protectedProcedure } from './trpc'; +import { canSendProcedure, projectProtectedProcedure, protectedProcedure } from './trpc'; const shareTypeSchema = z.enum(['chat', 'story']); const selectionSchema = z.object({ start: z.number(), end: z.number(), text: z.string() }); @@ -35,6 +35,34 @@ export const chatForkRoutes = { return forkSharedStoryItem(input.shareId, input.selection, ctx.user.id); }), + openStandalone: projectProtectedProcedure + .input(z.object({ storyId: z.string() })) + .mutation(async ({ input, ctx }): Promise<{ chatId: string }> => { + const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); + if (!story) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); + } + if (story.chatId) { + return { chatId: story.chatId }; + } + + const cache = await storyQueries.getStoryDataCacheByStoryId(story.id); + const seedMessages = cache?.queryData + ? buildQueryDataMessages(cache.queryData as Record) + : []; + + const chat = await chatQueries.createForkedChat( + { projectId: ctx.project.id, userId: ctx.user.id, title: story.title }, + seedMessages, + ); + + const latestVersion = await storyQueries.getLatestVersionByStoryId(story.id); + await storyQueries.assignChatToStory(story.id, chat.id); + await pinStoryMessageToChat(chat.id, story.slug, story.title, story.code, latestVersion?.version ?? 1); + + return { chatId: chat.id }; + }), + getSelectionForks: protectedProcedure .input(z.object({ shareId: z.string(), type: shareTypeSchema })) .query(async ({ input, ctx }) => { @@ -83,7 +111,7 @@ async function forkSharedStoryItem( : { type: 'story', id: share.storyId, title: share.title, authorName: share.authorName }; if (selection) { - const rawMessages = await chatQueries.getChatMessages(share.chatId); + const rawMessages = await chatQueries.getChatMessages(share.chatId!); const seededMessages = compactionService.useLastCompaction(rawMessages); const messages = [...seededMessages, buildSelectionContextMessage(share.title, selection)]; @@ -92,11 +120,11 @@ async function forkSharedStoryItem( messages, ); - await copyStoriesToFork(share.chatId, chat.id); + await copyStoriesToFork(share.chatId!, chat.id); return { chatId: chat.id }; } - const queryData = await sharedStoryQueries.getQueryDataFromCode(share.chatId, share.code); + const queryData = await sharedStoryQueries.getQueryDataFromCode(share.chatId!, share.code); const messages = buildQueryDataMessages(queryData); const chat = await chatQueries.createForkedChat({ projectId, userId, title: share.title, forkMetadata }, messages); @@ -205,6 +233,16 @@ async function createStoryInFork(chatId: string, slug: string, title: string, co source: 'assistant', }); + await pinStoryMessageToChat(chatId, slug, title, code, version.version); +} + +async function pinStoryMessageToChat( + chatId: string, + slug: string, + title: string, + code: string, + versionNumber: number, +): Promise { await chatQueries.upsertMessage({ chatId, role: 'assistant', @@ -215,7 +253,7 @@ async function createStoryInFork(chatId: string, slug: string, title: string, co toolName: 'story', state: 'output-available', input: { action: 'create', id: slug, title, code }, - output: { _version: '1', success: true, id: slug, version: version.version, code, title }, + output: { _version: '1', success: true, id: slug, version: versionNumber, code, title }, errorText: undefined, providerExecuted: false, } as UIMessagePart, @@ -231,7 +269,7 @@ async function copyStoriesToFork(sourceChatId: string, forkChatId: string): Prom await Promise.all( stories.map(async ({ slug }) => { - const latest = await storyQueries.getLatestVersion(sourceChatId, slug); + const latest = await storyQueries.getLatestVersionByChatAndSlug(sourceChatId, slug); if (!latest) { return; } diff --git a/apps/backend/src/trpc/mcp-endpoint.routes.ts b/apps/backend/src/trpc/mcp-endpoint.routes.ts new file mode 100644 index 000000000..e1791f7dd --- /dev/null +++ b/apps/backend/src/trpc/mcp-endpoint.routes.ts @@ -0,0 +1,36 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod/v4'; + +import * as mcpEndpointQueries from '../queries/mcp-endpoint.queries'; +import { adminProtectedProcedure, projectProtectedProcedure, protectedProcedure, router } from './trpc'; + +export const mcpEndpointRoutes = router({ + getSettings: projectProtectedProcedure.query(async ({ ctx }) => { + return mcpEndpointQueries.getMcpEndpointSettings(ctx.project.id); + }), + + updateSettings: adminProtectedProcedure + .input( + z.object({ + enabled: z.boolean().optional(), + agentModeEnabled: z.boolean().optional(), + toolsModeEnabled: z.boolean().optional(), + objectsModeEnabled: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return mcpEndpointQueries.updateMcpEndpointSettings(ctx.project.id, input); + }), + + getCallLogs: adminProtectedProcedure.query(async ({ ctx }) => { + return mcpEndpointQueries.getRecentMcpCallLogs(ctx.project.id); + }), + + getBearerToken: protectedProcedure.query(({ ctx }) => { + const token = ctx.session?.session?.token; + if (!token) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No active session.' }); + } + return { token }; + }), +}); diff --git a/apps/backend/src/trpc/router.ts b/apps/backend/src/trpc/router.ts index a73fa7269..81d48f282 100644 --- a/apps/backend/src/trpc/router.ts +++ b/apps/backend/src/trpc/router.ts @@ -12,6 +12,7 @@ import { githubRoutes } from './github.routes'; import { licenseRoutes } from './license.routes'; import { logRoutes } from './log.routes'; import { mcpRoutes } from './mcp.routes'; +import { mcpEndpointRoutes } from './mcp-endpoint.routes'; import { memoryRoutes } from './memory.routes'; import { organizationRoutes } from './organization.routes'; import { posthogRoutes } from './posthog.routes'; @@ -50,6 +51,7 @@ export const trpcRouter = router({ account: accountRoutes, apiKey: apiKeyRoutes, mcp: mcpRoutes, + mcpEndpoint: mcpEndpointRoutes, system: systemRoutes, skill: skillRoutes, transcribe: transcribeRoutes, diff --git a/apps/backend/src/trpc/shared-story.routes.ts b/apps/backend/src/trpc/shared-story.routes.ts index 7dc404b4b..128fde951 100644 --- a/apps/backend/src/trpc/shared-story.routes.ts +++ b/apps/backend/src/trpc/shared-story.routes.ts @@ -77,15 +77,14 @@ export const sharedStoryRoutes = { get: shareAccessProcedure.input(z.object({ shareId: z.string() })).query(async ({ ctx }) => { const shared = ctx.resource; - - const storyRow = await storyQueries.getStoryByChatAndSlug(shared.chatId, shared.slug); + const storyRow = await storyQueries.getStoryByChatAndSlug(shared.chatId!, shared.slug); const isLive = storyRow?.isLive ?? false; const isLiveTextDynamic = storyRow?.isLiveTextDynamic ?? false; const cacheSchedule = storyRow?.cacheSchedule ?? null; const cacheScheduleDescription = storyRow?.cacheScheduleDescription ?? null; const { queryData, cachedAt } = await getStoryQueryData( - shared.chatId, + shared.chatId!, shared.slug, shared.code, isLive, @@ -113,7 +112,7 @@ export const sharedStoryRoutes = { refreshData: shareAccessProcedure.input(z.object({ shareId: z.string() })).mutation(async ({ ctx }) => { const shared = ctx.resource; - const { queryData } = await refreshStoryData(shared.chatId, shared.slug); + const { queryData } = await refreshStoryData(shared.chatId!, shared.slug); return { queryData, cachedAt: new Date() }; }), @@ -183,14 +182,14 @@ export const sharedStoryRoutes = { const shared = ctx.resource; const version = input.versionNumber - ? await storyQueries.getVersionByNumber(shared.chatId, shared.slug, input.versionNumber) - : await storyQueries.getLatestVersion(shared.chatId, shared.slug); + ? await storyQueries.getVersionByNumber(shared.chatId!, shared.slug, input.versionNumber) + : await storyQueries.getLatestVersionByChatAndSlug(shared.chatId!, shared.slug); if (!version) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Story version not found.' }); } const { queryData } = await getStoryQueryData( - shared.chatId, + shared.chatId!, shared.slug, version.code, version.isLive, diff --git a/apps/backend/src/trpc/story.routes.ts b/apps/backend/src/trpc/story.routes.ts index e82d3959d..49e068bd5 100644 --- a/apps/backend/src/trpc/story.routes.ts +++ b/apps/backend/src/trpc/story.routes.ts @@ -14,7 +14,7 @@ const chatOwnerProcedure = ownedResourceProcedure(chatQueries.getChatOwnerId, 'c export const storyRoutes = { listAll: protectedProcedure.query(async ({ ctx }) => { - const stories = await storyQueries.listUserStories(ctx.user.id); + const stories = await storyQueries.listUserChatStories(ctx.user.id); return stories.map(({ code, ...rest }) => ({ ...rest, storySlug: rest.slug, @@ -23,7 +23,7 @@ export const storyRoutes = { }), listArchived: protectedProcedure.query(async ({ ctx }) => { - const stories = await storyQueries.listUserStories(ctx.user.id, { archived: true }); + const stories = await storyQueries.listUserChatStories(ctx.user.id, { archived: true }); return stories.map(({ code, ...rest }) => ({ ...rest, storySlug: rest.slug, @@ -31,10 +31,37 @@ export const storyRoutes = { })); }), + listStandalone: projectProtectedProcedure.query(async ({ ctx }) => { + const stories = await storyQueries.listUserStandaloneStories(ctx.user.id, ctx.project.id); + return stories.map(({ code, ...rest }) => ({ + ...rest, + storySlug: rest.slug, + summary: extractStorySummary(code), + })); + }), + + listStandaloneArchived: projectProtectedProcedure.query(async ({ ctx }) => { + const stories = await storyQueries.listUserStandaloneStories(ctx.user.id, ctx.project.id, { archived: true }); + return stories.map(({ code, ...rest }) => ({ + ...rest, + storySlug: rest.slug, + summary: extractStorySummary(code), + })); + }), + + getStandalone: projectProtectedProcedure.input(z.object({ storyId: z.string() })).query(async ({ input, ctx }) => { + const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); + if (!story) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); + } + const cache = await storyQueries.getStoryDataCacheByStoryId(input.storyId); + return { ...story, queryData: cache?.queryData ?? null }; + }), + getLatest: chatOwnerProcedure .input(z.object({ chatId: z.string(), storySlug: z.string() })) .query(async ({ input }) => { - const version = await storyQueries.getLatestVersion(input.chatId, input.storySlug); + const version = await storyQueries.getLatestVersionByChatAndSlug(input.chatId, input.storySlug); if (!version) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); } @@ -154,6 +181,26 @@ export const storyRoutes = { await storyQueries.unarchiveStory(input.chatId, input.storySlug); }), + archiveStandalone: projectProtectedProcedure + .input(z.object({ storyId: z.string() })) + .mutation(async ({ input, ctx }) => { + const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); + if (!story) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); + } + await storyQueries.archiveByStoryId(story.id); + }), + + unarchiveStandalone: projectProtectedProcedure + .input(z.object({ storyId: z.string() })) + .mutation(async ({ input, ctx }) => { + const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); + if (!story) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); + } + await storyQueries.unarchiveByStoryId(story.id); + }), + archiveMany: protectedProcedure .input(z.object({ stories: z.array(z.object({ chatId: z.string(), storySlug: z.string() })).min(1) })) .mutation(async ({ input, ctx }) => { @@ -169,6 +216,17 @@ export const storyRoutes = { await storyQueries.archiveManyStories(input.stories.map((s) => ({ chatId: s.chatId, slug: s.storySlug }))); }), + downloadStandalone: projectProtectedProcedure + .input(z.object({ storyId: z.string(), format: z.enum(DOWNLOAD_FORMATS) })) + .query(async ({ input, ctx }) => { + const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); + if (!story) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); + } + const cache = await storyQueries.getStoryDataCacheByStoryId(input.storyId); + return buildDownloadResponse(input.format, story.title, story.code, cache?.queryData ?? null); + }), + download: chatOwnerProcedure .input( z.object({ @@ -181,7 +239,7 @@ export const storyRoutes = { .query(async ({ input }) => { const version = input.versionNumber ? await storyQueries.getVersionByNumber(input.chatId, input.storySlug, input.versionNumber) - : await storyQueries.getLatestVersion(input.chatId, input.storySlug); + : await storyQueries.getLatestVersionByChatAndSlug(input.chatId, input.storySlug); if (!version) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); } diff --git a/apps/backend/src/types/mcp-endpoint.ts b/apps/backend/src/types/mcp-endpoint.ts new file mode 100644 index 000000000..978461e32 --- /dev/null +++ b/apps/backend/src/types/mcp-endpoint.ts @@ -0,0 +1,13 @@ +export interface McpEndpointSettings { + enabled: boolean; + agentModeEnabled: boolean; + toolsModeEnabled: boolean; + objectsModeEnabled: boolean; +} + +export const DEFAULT_MCP_ENDPOINT_SETTINGS: McpEndpointSettings = { + enabled: true, + agentModeEnabled: true, + toolsModeEnabled: true, + objectsModeEnabled: true, +}; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 2a8cadee4..2a608fe20 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -6,7 +6,6 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "declaration": true, "outDir": "./dist", "rootDir": "../..", "types": ["node", "bun-types"], diff --git a/apps/frontend/src/components/auth-form.tsx b/apps/frontend/src/components/auth-form.tsx index d4d48aef6..4405139a4 100644 --- a/apps/frontend/src/components/auth-form.tsx +++ b/apps/frontend/src/components/auth-form.tsx @@ -15,6 +15,7 @@ interface AuthFormProps { children: React.ReactNode; serverError?: string; displaySocialProviders?: boolean; + socialCallbackUrl?: string; footer?: React.ReactNode; } @@ -25,6 +26,7 @@ export function AuthForm({ children, serverError, displaySocialProviders, + socialCallbackUrl, footer, }: AuthFormProps) { const isGoogleSetup = useQuery(trpc.authConfig.google.isSetup.queryOptions()); @@ -49,7 +51,7 @@ export function AuthForm({ type='button' variant='outline' className='w-full h-11' - onClick={handleGoogleSignIn} + onClick={() => handleGoogleSignIn(socialCallbackUrl)} > Continue with Google @@ -60,7 +62,7 @@ export function AuthForm({ type='button' variant='outline' className='w-full h-11' - onClick={handleGithubSignIn} + onClick={() => handleGithubSignIn(socialCallbackUrl)} > Continue with GitHub diff --git a/apps/frontend/src/components/settings-search-index.ts b/apps/frontend/src/components/settings-search-index.ts index d6a52f7f1..d74a13c07 100644 --- a/apps/frontend/src/components/settings-search-index.ts +++ b/apps/frontend/src/components/settings-search-index.ts @@ -189,6 +189,39 @@ export const settingsSearchIndex: SettingsSearchEntry[] = [ adminOnly: true, }, + // ── MCP Endpoint ──────────────────────────────────────── + { + page: '/settings/mcp-endpoint', + pageLabel: 'MCP Endpoint', + title: 'MCP Server Endpoint', + description: 'Allow external AI clients to connect to this workspace via MCP.', + keywords: ['model context protocol', 'claude desktop', 'cursor', 'external', 'api', 'bearer'], + }, + { + page: '/settings/mcp-endpoint', + pageLabel: 'MCP Endpoint', + section: 'MCP Modes', + title: 'Agent mode', + description: 'Let external agents ask analytics questions via ask_nao.', + keywords: ['ask_nao', 'agent', 'analytics'], + }, + { + page: '/settings/mcp-endpoint', + pageLabel: 'MCP Endpoint', + section: 'MCP Modes', + title: 'Tools mode', + description: 'Let external agents run SQL queries directly via execute_sql.', + keywords: ['execute_sql', 'sql', 'query'], + }, + { + page: '/settings/mcp-endpoint', + pageLabel: 'MCP Endpoint', + section: 'MCP Modes', + title: 'Objects mode', + description: 'Let external agents create and manage stories.', + keywords: ['stories', 'dashboard', 'report', 'crud'], + }, + // ── Project > Slack ────────────────────────────────────── { page: '/settings/project/slack', diff --git a/apps/frontend/src/components/settings/mcp-endpoint.tsx b/apps/frontend/src/components/settings/mcp-endpoint.tsx new file mode 100644 index 000000000..90a3cf6e8 --- /dev/null +++ b/apps/frontend/src/components/settings/mcp-endpoint.tsx @@ -0,0 +1,448 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Check, ChevronLeft, ChevronRight, Copy } from 'lucide-react'; +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { SettingsCard } from '@/components/ui/settings-card'; +import { SettingsToggleRow } from '@/components/ui/settings-toggle-row'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { trpc } from '@/main'; + +interface ProjectItem { + id: string; + name: string; +} + +interface Props { + isAdmin: boolean; + projects?: ProjectItem[]; +} + +export function McpEndpointSettings({ isAdmin }: Props) { + const queryClient = useQueryClient(); + const settingsQuery = useQuery(trpc.mcpEndpoint.getSettings.queryOptions()); + const callLogsQuery = useQuery({ + ...trpc.mcpEndpoint.getCallLogs.queryOptions(), + refetchInterval: 30_000, + enabled: isAdmin, + }); + + const updateMutation = useMutation( + trpc.mcpEndpoint.updateSettings.mutationOptions({ + onMutate: async (newSettings) => { + const queryKey = trpc.mcpEndpoint.getSettings.queryOptions().queryKey; + await queryClient.cancelQueries({ queryKey }); + const prev = queryClient.getQueryData(queryKey); + if (prev) { + queryClient.setQueryData(queryKey, { ...prev, ...newSettings }); + } + return { prev }; + }, + onError: (_err, _vars, context) => { + if (context?.prev) { + queryClient.setQueryData(trpc.mcpEndpoint.getSettings.queryOptions().queryKey, context.prev); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: trpc.mcpEndpoint.getSettings.queryOptions().queryKey }); + }, + }), + ); + + const settings = settingsQuery.data; + const enabled = settings?.enabled ?? true; + const pending = updateMutation.isPending; + + const toggle = (field: string, value: boolean) => { + updateMutation.mutate({ [field]: value }); + }; + + return ( + <> + + toggle('enabled', v)} + disabled={!isAdmin || pending} + /> + + + + toggle('agentModeEnabled', v)} + disabled={!isAdmin || !enabled || pending} + /> + toggle('toolsModeEnabled', v)} + disabled={!isAdmin || !enabled || pending} + /> + toggle('objectsModeEnabled', v)} + disabled={!isAdmin || !enabled || pending} + /> + + + + + {isAdmin && ( + + )} + + ); +} + +type Method = { + label?: string; + steps: string[]; + config?: string; + configLabel?: string; +}; + +type Provider = { + id: string; + label: string; + methods: Method[]; +}; + +const TOKEN_PLACEHOLDER = ''; + +function ConnectionCard() { + const endpointUrl = `${window.location.origin}/mcp`; + + const cursorConfig = JSON.stringify({ mcpServers: { nao: { type: 'http', url: endpointUrl } } }, null, 2); + + const claudeDesktopConfig = JSON.stringify( + { mcpServers: { nao: { command: 'npx', args: ['-y', 'mcp-remote', endpointUrl] } } }, + null, + 2, + ); + + const manualTokenConfig = JSON.stringify( + { + mcpServers: { + nao: { + type: 'http', + url: endpointUrl, + headers: { Authorization: `Bearer ${TOKEN_PLACEHOLDER}` }, + }, + }, + }, + null, + 2, + ); + + const providers: Provider[] = [ + { + id: 'cursor', + label: 'Cursor', + methods: [ + { + steps: [ + 'Open {Settings > Tools & MCP}.', + 'Click `New MCP Server` and paste the JSON below — or edit `.cursor/mcp.json` manually.', + 'Authenticate in your browser when prompted.', + ], + config: cursorConfig, + configLabel: 'JSON config', + }, + ], + }, + { + id: 'codex', + label: 'Codex', + methods: [ + { + steps: [ + 'Open {Settings > MCP servers > + Add server > Streamable HTTP}.', + 'Paste the URL below into the URL field, then save.', + 'Authenticate in your browser when prompted.', + ], + config: endpointUrl, + configLabel: 'Endpoint URL', + }, + ], + }, + { + id: 'claude-code', + label: 'Claude Code', + methods: [ + { + steps: ['Open `/.claude/settings.local.json`.', 'Paste the config below.'], + config: manualTokenConfig, + configLabel: 'JSON config', + }, + ], + }, + { + id: 'claude-desktop', + label: 'Claude Desktop', + methods: [ + { + label: 'Via Settings UI', + steps: [ + 'Open {Settings > Connectors > Add custom connector}.', + 'Set `Name` to anything, and `Remote MCP Server URL` to the URL below.', + 'Enable the connector and authenticate in your browser.', + ], + config: endpointUrl, + configLabel: 'Endpoint URL', + }, + { + label: 'Via config file', + steps: [ + 'Open `claude_desktop_config.json`, generally located in `~/Library/Application Support/Claude/`.', + 'Add the server using the JSON below.', + 'Restart Claude Desktop and authenticate when prompted.', + ], + config: claudeDesktopConfig, + configLabel: 'JSON config', + }, + ], + }, + { + id: 'cli', + label: 'CLI / Scripts', + methods: [ + { + steps: ['Use the config below in your MCP client.'], + config: manualTokenConfig, + configLabel: 'JSON config', + }, + ], + }, + ]; + + const [active, setActive] = useState(0); + const selected = providers[active]; + + const needsToken = selected.methods.some((m) => m.config?.includes(TOKEN_PLACEHOLDER)); + const tokenQuery = useQuery({ + ...trpc.mcpEndpoint.getBearerToken.queryOptions(), + enabled: needsToken, + staleTime: Infinity, + }); + + const resolveConfig = (config: string) => { + if (!config.includes(TOKEN_PLACEHOLDER)) { + return config; + } + const token = tokenQuery.data?.token; + return token ? config.replaceAll(TOKEN_PLACEHOLDER, token) : config; + }; + + const goPrev = () => setActive((i) => (i === 0 ? providers.length - 1 : i - 1)); + const goNext = () => setActive((i) => (i === providers.length - 1 ? 0 : i + 1)); + + return ( + +
+
+ {providers.map((p, i) => ( + + ))} +
+ + +
+
+ +
+ {selected.methods.map((method, i) => ( +
0 ? 'pt-3' : ''} ${i < selected.methods.length - 1 ? 'pb-3' : ''}`} + > + {method.label && ( +

+ Method {i + 1} — {method.label} +

+ )} + + {method.config && ( + + )} +
+ ))} +
+
+
+ ); +} + +function StepsList({ steps }: { steps: string[] }) { + return ( +
    + {steps.map((step, i) => ( +
  1. {renderInline(step)}
  2. + ))} +
+ ); +} + +function ConfigBlock({ label, text }: { label: string; text: string }) { + return ( +
+
+ {label} + +
+
+				{text}
+			
+
+ ); +} + +/** + * Renders a string with two inline syntaxes: + * - `code` → small code badge (bg, monospace) — for file paths, JSON keys, identifiers + * - {Foo > Bar > Baz} → breadcrumb (no bg, monospace, › separators) — for menu paths + */ +function renderInline(text: string) { + const parts = text.split(/(\{[^}]+\}|`[^`]+`)/g); + return parts.map((part, i) => { + if (part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ); + } + if (part.startsWith('{') && part.endsWith('}')) { + const segments = part.slice(1, -1).split(/\s*>\s*/); + return ( + + {segments.map((seg, j) => ( + + {j > 0 && } + {seg} + + ))} + + ); + } + return {part}; + }); +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} + +type CallLog = { + id: string; + userId: string; + userName: string | null; + toolName: string; + durationMs: number | null; + success: boolean; + calledAt: Date; +}; + +function CallLogsCard({ logs, isLoading, isError }: { logs: CallLog[]; isLoading: boolean; isError: boolean }) { + return ( + + {isLoading ? ( +

Loading…

+ ) : isError ? ( +

Failed to load MCP call logs.

+ ) : logs.length === 0 ? ( +

No MCP calls recorded yet.

+ ) : ( +
+ + + Time + User + Tool + Status + + + + {logs.map((log) => ( + + + {formatRelativeTime(log.calledAt)} + + {log.userName ?? 'Unknown'} + + {log.toolName} + + + + {log.success ? 'OK' : 'Error'} + + + + ))} + +
+ )} + + ); +} + +function formatRelativeTime(date: Date): string { + const diff = Date.now() - new Date(date).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) { + return 'now'; + } + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + return `${Math.floor(hours / 24)}d`; +} diff --git a/apps/frontend/src/components/sidebar-settings-nav.tsx b/apps/frontend/src/components/sidebar-settings-nav.tsx index 97c07c2b0..511d969b8 100644 --- a/apps/frontend/src/components/sidebar-settings-nav.tsx +++ b/apps/frontend/src/components/sidebar-settings-nav.tsx @@ -46,6 +46,10 @@ const settingsNavItems: NavItem[] = [ to: '/settings/project', visible: ({ isViewer, isInMultipleProjects }) => !isViewer || isInMultipleProjects, }, + { + label: 'MCP Endpoint', + to: '/settings/mcp-endpoint', + }, { label: 'Observability', type: 'divider', diff --git a/apps/frontend/src/components/stories-groups.tsx b/apps/frontend/src/components/stories-groups.tsx index bf326cbd5..3842f0ce0 100644 --- a/apps/frontend/src/components/stories-groups.tsx +++ b/apps/frontend/src/components/stories-groups.tsx @@ -117,7 +117,9 @@ function StoryCard({ displayMode: DisplayMode; showArchived: boolean; }) { - if (item.kind !== 'own' || !item.chatId || !item.storySlug) { + const actionMenu = renderActionMenuForItem(item, displayMode, showArchived); + + if (!actionMenu) { return ( @@ -128,17 +130,29 @@ function StoryCard({ return ( - + ); +} + +function renderActionMenuForItem(item: StoryItem, displayMode: DisplayMode, showArchived: boolean) { + if (item.kind === 'own' && item.chatId && item.storySlug) { + return ( + - - ); + ); + } + if (item.kind === 'own-standalone') { + return ; + } + return null; } -function StoryActionMenu({ +function OwnStoryActionMenu({ chatId, storySlug, displayMode, @@ -168,8 +182,6 @@ function StoryActionMenu({ }), ); - const pending = archiveMutation.isPending || unarchiveMutation.isPending; - function handleSelect() { if (showArchived) { unarchiveMutation.mutate({ chatId, storySlug }); @@ -178,6 +190,74 @@ function StoryActionMenu({ } } + return ( + + ); +} + +function StandaloneStoryActionMenu({ + storyId, + displayMode, + showArchived, +}: { + storyId: string; + displayMode: DisplayMode; + showArchived: boolean; +}) { + const queryClient = useQueryClient(); + + const archiveMutation = useMutation( + trpc.story.archiveStandalone.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.story.listStandalone.queryKey() }); + queryClient.invalidateQueries({ queryKey: trpc.story.listStandaloneArchived.queryKey() }); + }, + }), + ); + + const unarchiveMutation = useMutation( + trpc.story.unarchiveStandalone.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.story.listStandalone.queryKey() }); + queryClient.invalidateQueries({ queryKey: trpc.story.listStandaloneArchived.queryKey() }); + }, + }), + ); + + function handleSelect() { + if (showArchived) { + unarchiveMutation.mutate({ storyId }); + } else { + archiveMutation.mutate({ storyId }); + } + } + + return ( + + ); +} + +function ArchiveActionMenu({ + displayMode, + showArchived, + pending, + onSelect, +}: { + displayMode: DisplayMode; + showArchived: boolean; + pending: boolean; + onSelect: () => void; +}) { return ( @@ -198,7 +278,7 @@ function StoryActionMenu({ e.stopPropagation()}> - + {showArchived ? : } {showArchived ? 'Unarchive' : 'Archive'} diff --git a/apps/frontend/src/components/story-download.tsx b/apps/frontend/src/components/story-download.tsx index bd9821261..cef2174c3 100644 --- a/apps/frontend/src/components/story-download.tsx +++ b/apps/frontend/src/components/story-download.tsx @@ -14,17 +14,25 @@ import { import { trpcClient } from '@/main'; interface StoryDownloadOptions { - chatId: string; - storySlug: string; + storyId?: string; + chatId?: string; + storySlug?: string; shareId?: string; isOwner?: boolean; versionNumber?: number; } -function useStoryDownload({ chatId, storySlug, shareId, isOwner = true, versionNumber }: StoryDownloadOptions) { +function useStoryDownload({ + storyId, + chatId, + storySlug, + shareId, + isOwner = true, + versionNumber, +}: StoryDownloadOptions) { const [isDownloading, setIsDownloading] = useState(false); const [error, setError] = useState(null); - const canDownload = isOwner || !!shareId; + const canDownload = isOwner || !!shareId || !!storyId; const handleDownload = async (format: DownloadFormat) => { if (!canDownload) { @@ -33,9 +41,19 @@ function useStoryDownload({ chatId, storySlug, shareId, isOwner = true, versionN setIsDownloading(true); setError(null); try { - const result = isOwner - ? await trpcClient.story.download.query({ chatId, storySlug, format, versionNumber }) - : await trpcClient.storyShare.download.query({ shareId: shareId!, format, versionNumber }); + let result; + if (storyId) { + result = await trpcClient.story.downloadStandalone.query({ storyId, format }); + } else if (isOwner) { + result = await trpcClient.story.download.query({ + chatId: chatId!, + storySlug: storySlug!, + format, + versionNumber, + }); + } else { + result = await trpcClient.storyShare.download.query({ shareId: shareId!, format, versionNumber }); + } const bytes = Uint8Array.from(atob(result.data), (c) => c.charCodeAt(0)); const blob = new Blob([bytes], { type: result.mimeType }); const url = URL.createObjectURL(blob); diff --git a/apps/frontend/src/components/tool-calls/mcp.tsx b/apps/frontend/src/components/tool-calls/mcp.tsx index 1c69b0ded..52e5f7000 100644 --- a/apps/frontend/src/components/tool-calls/mcp.tsx +++ b/apps/frontend/src/components/tool-calls/mcp.tsx @@ -1,12 +1,79 @@ import { Streamdown } from 'streamdown'; -import { ToolCallWrapper } from './tool-call-wrapper'; +import { parseChartBlock } from '@nao/shared/story-segments'; +import { ChartDisplay } from './display-chart'; import { TableDisplay } from './display-table'; +import { ToolCallWrapper } from './tool-call-wrapper'; import type { ToolCallComponentProps } from '.'; +import type { displayChart } from '@nao/shared/tools'; +import type { UIMessage, UIToolPart } from '@nao/backend/chat'; import { getToolName } from '@/lib/ai'; +import { useOptionalAgentContext } from '@/contexts/agent.provider'; import { useToolCallContext } from '@/contexts/tool-call'; +const EMPTY_MESSAGES: UIMessage[] = []; + type McpContent = { type: string; text: string }; +const extractChartBlock = (output: unknown): string | null => { + if (output && typeof output === 'object' && !Array.isArray(output)) { + const block = (output as Record).block; + if (typeof block === 'string' && /^[] | null => { + for (const message of messages) { + for (const part of message.parts) { + const output = (part as UIToolPart).output; + if (output && typeof output === 'object' && !Array.isArray(output)) { + const typed = output as Record; + if (typed.query_id === queryId && Array.isArray(typed.data)) { + return typed.data as Record[]; + } + } + } + } + return null; +}; + +const McpChartOutput = ({ chartBlock }: { chartBlock: string }) => { + const agent = useOptionalAgentContext(); + const messages = agent?.messages ?? EMPTY_MESSAGES; + + const attrString = chartBlock.match(/^$/)?.[1] ?? ''; + const chart = parseChartBlock(attrString); + + if (!chart || chart.series.length === 0) { + return null; + } + + const data = findSqlData(messages, chart.queryId); + + if (!data || data.length === 0) { + return ( +
+ Chart data unavailable +
+ ); + } + + return ( +
+ +
+ ); +}; + const extractText = (output: unknown): string | null => { if (typeof output === 'string') { return output; @@ -81,7 +148,14 @@ const McpOutputContent = ({ text }: { text: string }) => { export const McpToolCall = ({ toolPart }: ToolCallComponentProps) => { const { isSettled } = useToolCallContext(); const toolName = getToolName(toolPart); - const text = isSettled ? extractText(toolPart.output) : null; + if (isSettled) { + const chartBlock = extractChartBlock(toolPart.output); + if (chartBlock) { + return ; + } + } + + const text = isSettled ? extractText(toolPart.output) : null; return {text !== null && }; }; diff --git a/apps/frontend/src/lib/auth-client.ts b/apps/frontend/src/lib/auth-client.ts index fb8cfb596..d8b83bcf8 100644 --- a/apps/frontend/src/lib/auth-client.ts +++ b/apps/frontend/src/lib/auth-client.ts @@ -18,18 +18,18 @@ export const authClient = createAuthClient({ export const { useSession, signIn, signUp, signOut, requestPasswordReset, resetPassword } = authClient; -const handleGoogleSignIn = async () => { +const handleGoogleSignIn = async (callbackURL = '/') => { await authClient.signIn.social({ provider: 'google', - callbackURL: '/', + callbackURL, errorCallbackURL: '/login', }); }; -const handleGithubSignIn = async () => { +const handleGithubSignIn = async (callbackURL = '/') => { await authClient.signIn.social({ provider: 'github', - callbackURL: '/', + callbackURL, errorCallbackURL: '/login', }); }; diff --git a/apps/frontend/src/lib/stories-page.ts b/apps/frontend/src/lib/stories-page.ts index ab4970b68..3928d6a60 100644 --- a/apps/frontend/src/lib/stories-page.ts +++ b/apps/frontend/src/lib/stories-page.ts @@ -17,29 +17,32 @@ export type StoryItem = { title: string; createdAt: Date; author: string; - kind: 'own' | 'shared-with-me' | 'shared-project'; + kind: 'own' | 'own-standalone' | 'shared-with-me' | 'shared-project'; chatId?: string; storySlug?: string; summary: StorySummary; link: | { to: '/stories/preview/$chatId/$storySlug'; params: { chatId: string; storySlug: string } } - | { to: '/stories/shared/$shareId'; params: { shareId: string } }; + | { to: '/stories/shared/$shareId'; params: { shareId: string } } + | { to: '/stories/standalone/$storyId'; params: { storyId: string } }; }; export type StoryGroup = { label: string; items: StoryItem[] }; export type OwnStoryListItem = { - chatId: string; + id?: string; + chatId?: string | null; storySlug: string; title: string; createdAt: Date | string; summary: StorySummary; + isStandalone?: boolean; }; export type SharedStoryListItem = { id: string; userId: string; - chatId: string; + chatId: string | null; storySlug: string; title: string; createdAt: Date | string; @@ -55,18 +58,20 @@ export function getStoredSetting(key: string, allowed: T[], fa export function buildStoryItems({ userStories, + standaloneStories, sharedStories, currentUserId, currentUserName, }: { userStories: OwnStoryListItem[]; + standaloneStories?: OwnStoryListItem[]; sharedStories: SharedStoryListItem[]; currentUserId?: string; currentUserName: string; }): StoryItem[] { const ownShareMap = new Map(); for (const story of sharedStories) { - if (story.userId === currentUserId) { + if (story.userId === currentUserId && story.chatId) { const key = `${story.chatId}-${story.storySlug}`; if (!ownShareMap.has(key)) { ownShareMap.set(key, story.id); @@ -75,25 +80,37 @@ export function buildStoryItems({ } const ownItems: StoryItem[] = userStories.map((story) => { - const shareId = ownShareMap.get(`${story.chatId}-${story.storySlug}`); + const chatId = story.chatId!; + const shareId = ownShareMap.get(`${chatId}-${story.storySlug}`); return { - id: `${story.chatId}-${story.storySlug}`, + id: `${chatId}-${story.storySlug}`, title: story.title, createdAt: new Date(story.createdAt), author: currentUserName, kind: 'own', - chatId: story.chatId, + chatId, storySlug: story.storySlug, summary: story.summary, link: shareId ? { to: '/stories/shared/$shareId', params: { shareId } } : { to: '/stories/preview/$chatId/$storySlug', - params: { chatId: story.chatId, storySlug: story.storySlug }, + params: { chatId, storySlug: story.storySlug }, }, }; }); + const standaloneItems: StoryItem[] = (standaloneStories ?? []).map((story) => ({ + id: story.id ?? story.storySlug, + title: story.title, + createdAt: new Date(story.createdAt), + author: currentUserName, + kind: 'own-standalone', + storySlug: story.storySlug, + summary: story.summary, + link: { to: '/stories/standalone/$storyId', params: { storyId: story.id! } }, + })); + const sharedItems: StoryItem[] = sharedStories .filter((story) => story.userId !== currentUserId) .map((story) => ({ @@ -106,7 +123,7 @@ export function buildStoryItems({ link: { to: '/stories/shared/$shareId', params: { shareId: story.id } }, })); - return [...ownItems, ...sharedItems]; + return [...ownItems, ...standaloneItems, ...sharedItems]; } export function filterStories(items: StoryItem[], query: string): StoryItem[] { @@ -139,24 +156,28 @@ export function groupStories(items: StoryItem[], groupBy: GroupBy): StoryGroup[] } function groupByOwnership(items: StoryItem[]): StoryGroup[] { - const own = items.filter((item) => item.kind === 'own'); + const own = items.filter((item) => item.kind === 'own' || item.kind === 'own-standalone'); const sharedWithMe = items.filter((item) => item.kind === 'shared-with-me'); const sharedProject = items.filter((item) => item.kind === 'shared-project'); const groups: StoryGroup[] = []; if (own.length > 0) { - groups.push({ label: 'My Stories', items: own }); + groups.push({ label: 'My Stories', items: sortByCreatedAtDesc(own) }); } if (sharedWithMe.length > 0) { - groups.push({ label: 'Shared with Me', items: sharedWithMe }); + groups.push({ label: 'Shared with Me', items: sortByCreatedAtDesc(sharedWithMe) }); } if (sharedProject.length > 0) { - groups.push({ label: 'Shared with the Project', items: sharedProject }); + groups.push({ label: 'Shared with the Project', items: sortByCreatedAtDesc(sharedProject) }); } return groups; } +function sortByCreatedAtDesc(items: StoryItem[]): StoryItem[] { + return [...items].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); +} + function groupByDate(items: StoryItem[]): StoryGroup[] { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); @@ -205,7 +226,10 @@ function groupByUser(items: StoryItem[]): StoryGroup[] { } } - return [...groupedByAuthor.entries()].map(([label, group]) => ({ label, items: group })); + return [...groupedByAuthor.entries()].map(([label, group]) => ({ + label, + items: sortByCreatedAtDesc(group), + })); } function extractSummaryText(summary: StorySummary): string { diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 68c187945..d7bc84465 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as SidebarLayoutSettingsUsageRouteImport } from './routes/_sideba import { Route as SidebarLayoutSettingsProjectRouteImport } from './routes/_sidebar-layout.settings.project' import { Route as SidebarLayoutSettingsOrganizationRouteImport } from './routes/_sidebar-layout.settings.organization' import { Route as SidebarLayoutSettingsMemoryRouteImport } from './routes/_sidebar-layout.settings.memory' +import { Route as SidebarLayoutSettingsMcpEndpointRouteImport } from './routes/_sidebar-layout.settings.mcp-endpoint' import { Route as SidebarLayoutSettingsLogsRouteImport } from './routes/_sidebar-layout.settings.logs' import { Route as SidebarLayoutSettingsEnterpriseRouteImport } from './routes/_sidebar-layout.settings.enterprise' import { Route as SidebarLayoutSettingsContextExplorerRouteImport } from './routes/_sidebar-layout.settings.context-explorer' @@ -31,6 +32,7 @@ import { Route as SidebarLayoutSettingsChatsReplayRouteImport } from './routes/_ import { Route as SidebarLayoutSettingsAccountRouteImport } from './routes/_sidebar-layout.settings.account' import { Route as SidebarLayoutChatLayoutChatIdRouteImport } from './routes/_sidebar-layout._chat-layout.$chatId' import { Route as SidebarLayoutSettingsProjectIndexRouteImport } from './routes/_sidebar-layout.settings.project.index' +import { Route as SidebarLayoutStoriesStandaloneStoryIdRouteImport } from './routes/_sidebar-layout.stories.standalone.$storyId' import { Route as SidebarLayoutStoriesSharedShareIdRouteImport } from './routes/_sidebar-layout.stories.shared.$shareId' import { Route as SidebarLayoutSettingsProjectWhatsappRouteImport } from './routes/_sidebar-layout.settings.project.whatsapp' import { Route as SidebarLayoutSettingsProjectTelegramRouteImport } from './routes/_sidebar-layout.settings.project.telegram' @@ -39,6 +41,7 @@ import { Route as SidebarLayoutSettingsProjectTeamRouteImport } from './routes/_ import { Route as SidebarLayoutSettingsProjectSlackRouteImport } from './routes/_sidebar-layout.settings.project.slack' import { Route as SidebarLayoutSettingsProjectModelsRouteImport } from './routes/_sidebar-layout.settings.project.models' import { Route as SidebarLayoutSettingsProjectMcpServersRouteImport } from './routes/_sidebar-layout.settings.project.mcp-servers' +import { Route as SidebarLayoutSettingsProjectMcpEndpointRouteImport } from './routes/_sidebar-layout.settings.project.mcp-endpoint' import { Route as SidebarLayoutSettingsProjectBudgetsRouteImport } from './routes/_sidebar-layout.settings.project.budgets' import { Route as SidebarLayoutSettingsProjectAgentRouteImport } from './routes/_sidebar-layout.settings.project.agent' import { Route as SidebarLayoutStoriesPreviewChatIdStorySlugRouteImport } from './routes/_sidebar-layout.stories.preview.$chatId.$storySlug' @@ -124,6 +127,12 @@ const SidebarLayoutSettingsMemoryRoute = path: '/memory', getParentRoute: () => SidebarLayoutSettingsRoute, } as any) +const SidebarLayoutSettingsMcpEndpointRoute = + SidebarLayoutSettingsMcpEndpointRouteImport.update({ + id: '/mcp-endpoint', + path: '/mcp-endpoint', + getParentRoute: () => SidebarLayoutSettingsRoute, + } as any) const SidebarLayoutSettingsLogsRoute = SidebarLayoutSettingsLogsRouteImport.update({ id: '/logs', @@ -166,6 +175,12 @@ const SidebarLayoutSettingsProjectIndexRoute = path: '/', getParentRoute: () => SidebarLayoutSettingsProjectRoute, } as any) +const SidebarLayoutStoriesStandaloneStoryIdRoute = + SidebarLayoutStoriesStandaloneStoryIdRouteImport.update({ + id: '/stories/standalone/$storyId', + path: '/stories/standalone/$storyId', + getParentRoute: () => SidebarLayoutRoute, + } as any) const SidebarLayoutStoriesSharedShareIdRoute = SidebarLayoutStoriesSharedShareIdRouteImport.update({ id: '/stories/shared/$shareId', @@ -214,6 +229,12 @@ const SidebarLayoutSettingsProjectMcpServersRoute = path: '/mcp-servers', getParentRoute: () => SidebarLayoutSettingsProjectRoute, } as any) +const SidebarLayoutSettingsProjectMcpEndpointRoute = + SidebarLayoutSettingsProjectMcpEndpointRouteImport.update({ + id: '/mcp-endpoint', + path: '/mcp-endpoint', + getParentRoute: () => SidebarLayoutSettingsProjectRoute, + } as any) const SidebarLayoutSettingsProjectBudgetsRoute = SidebarLayoutSettingsProjectBudgetsRouteImport.update({ id: '/budgets', @@ -246,6 +267,7 @@ export interface FileRoutesByFullPath { '/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/settings/logs': typeof SidebarLayoutSettingsLogsRoute + '/settings/mcp-endpoint': typeof SidebarLayoutSettingsMcpEndpointRoute '/settings/memory': typeof SidebarLayoutSettingsMemoryRoute '/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/settings/project': typeof SidebarLayoutSettingsProjectRouteWithChildren @@ -255,6 +277,7 @@ export interface FileRoutesByFullPath { '/stories/': typeof SidebarLayoutStoriesIndexRoute '/settings/project/agent': typeof SidebarLayoutSettingsProjectAgentRoute '/settings/project/budgets': typeof SidebarLayoutSettingsProjectBudgetsRoute + '/settings/project/mcp-endpoint': typeof SidebarLayoutSettingsProjectMcpEndpointRoute '/settings/project/mcp-servers': typeof SidebarLayoutSettingsProjectMcpServersRoute '/settings/project/models': typeof SidebarLayoutSettingsProjectModelsRoute '/settings/project/slack': typeof SidebarLayoutSettingsProjectSlackRoute @@ -263,6 +286,7 @@ export interface FileRoutesByFullPath { '/settings/project/telegram': typeof SidebarLayoutSettingsProjectTelegramRoute '/settings/project/whatsapp': typeof SidebarLayoutSettingsProjectWhatsappRoute '/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute + '/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/settings/project/': typeof SidebarLayoutSettingsProjectIndexRoute '/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } @@ -278,6 +302,7 @@ export interface FileRoutesByTo { '/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/settings/logs': typeof SidebarLayoutSettingsLogsRoute + '/settings/mcp-endpoint': typeof SidebarLayoutSettingsMcpEndpointRoute '/settings/memory': typeof SidebarLayoutSettingsMemoryRoute '/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/settings/usage': typeof SidebarLayoutSettingsUsageRoute @@ -286,6 +311,7 @@ export interface FileRoutesByTo { '/stories': typeof SidebarLayoutStoriesIndexRoute '/settings/project/agent': typeof SidebarLayoutSettingsProjectAgentRoute '/settings/project/budgets': typeof SidebarLayoutSettingsProjectBudgetsRoute + '/settings/project/mcp-endpoint': typeof SidebarLayoutSettingsProjectMcpEndpointRoute '/settings/project/mcp-servers': typeof SidebarLayoutSettingsProjectMcpServersRoute '/settings/project/models': typeof SidebarLayoutSettingsProjectModelsRoute '/settings/project/slack': typeof SidebarLayoutSettingsProjectSlackRoute @@ -294,6 +320,7 @@ export interface FileRoutesByTo { '/settings/project/telegram': typeof SidebarLayoutSettingsProjectTelegramRoute '/settings/project/whatsapp': typeof SidebarLayoutSettingsProjectWhatsappRoute '/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute + '/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/settings/project': typeof SidebarLayoutSettingsProjectIndexRoute '/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } @@ -312,6 +339,7 @@ export interface FileRoutesById { '/_sidebar-layout/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/_sidebar-layout/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/_sidebar-layout/settings/logs': typeof SidebarLayoutSettingsLogsRoute + '/_sidebar-layout/settings/mcp-endpoint': typeof SidebarLayoutSettingsMcpEndpointRoute '/_sidebar-layout/settings/memory': typeof SidebarLayoutSettingsMemoryRoute '/_sidebar-layout/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/_sidebar-layout/settings/project': typeof SidebarLayoutSettingsProjectRouteWithChildren @@ -322,6 +350,7 @@ export interface FileRoutesById { '/_sidebar-layout/stories/': typeof SidebarLayoutStoriesIndexRoute '/_sidebar-layout/settings/project/agent': typeof SidebarLayoutSettingsProjectAgentRoute '/_sidebar-layout/settings/project/budgets': typeof SidebarLayoutSettingsProjectBudgetsRoute + '/_sidebar-layout/settings/project/mcp-endpoint': typeof SidebarLayoutSettingsProjectMcpEndpointRoute '/_sidebar-layout/settings/project/mcp-servers': typeof SidebarLayoutSettingsProjectMcpServersRoute '/_sidebar-layout/settings/project/models': typeof SidebarLayoutSettingsProjectModelsRoute '/_sidebar-layout/settings/project/slack': typeof SidebarLayoutSettingsProjectSlackRoute @@ -330,6 +359,7 @@ export interface FileRoutesById { '/_sidebar-layout/settings/project/telegram': typeof SidebarLayoutSettingsProjectTelegramRoute '/_sidebar-layout/settings/project/whatsapp': typeof SidebarLayoutSettingsProjectWhatsappRoute '/_sidebar-layout/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute + '/_sidebar-layout/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/_sidebar-layout/settings/project/': typeof SidebarLayoutSettingsProjectIndexRoute '/_sidebar-layout/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } @@ -348,6 +378,7 @@ export interface FileRouteTypes { | '/settings/context-explorer' | '/settings/enterprise' | '/settings/logs' + | '/settings/mcp-endpoint' | '/settings/memory' | '/settings/organization' | '/settings/project' @@ -357,6 +388,7 @@ export interface FileRouteTypes { | '/stories/' | '/settings/project/agent' | '/settings/project/budgets' + | '/settings/project/mcp-endpoint' | '/settings/project/mcp-servers' | '/settings/project/models' | '/settings/project/slack' @@ -365,6 +397,7 @@ export interface FileRouteTypes { | '/settings/project/telegram' | '/settings/project/whatsapp' | '/stories/shared/$shareId' + | '/stories/standalone/$storyId' | '/settings/project/' | '/stories/preview/$chatId/$storySlug' fileRoutesByTo: FileRoutesByTo @@ -380,6 +413,7 @@ export interface FileRouteTypes { | '/settings/context-explorer' | '/settings/enterprise' | '/settings/logs' + | '/settings/mcp-endpoint' | '/settings/memory' | '/settings/organization' | '/settings/usage' @@ -388,6 +422,7 @@ export interface FileRouteTypes { | '/stories' | '/settings/project/agent' | '/settings/project/budgets' + | '/settings/project/mcp-endpoint' | '/settings/project/mcp-servers' | '/settings/project/models' | '/settings/project/slack' @@ -396,6 +431,7 @@ export interface FileRouteTypes { | '/settings/project/telegram' | '/settings/project/whatsapp' | '/stories/shared/$shareId' + | '/stories/standalone/$storyId' | '/settings/project' | '/stories/preview/$chatId/$storySlug' id: @@ -413,6 +449,7 @@ export interface FileRouteTypes { | '/_sidebar-layout/settings/context-explorer' | '/_sidebar-layout/settings/enterprise' | '/_sidebar-layout/settings/logs' + | '/_sidebar-layout/settings/mcp-endpoint' | '/_sidebar-layout/settings/memory' | '/_sidebar-layout/settings/organization' | '/_sidebar-layout/settings/project' @@ -423,6 +460,7 @@ export interface FileRouteTypes { | '/_sidebar-layout/stories/' | '/_sidebar-layout/settings/project/agent' | '/_sidebar-layout/settings/project/budgets' + | '/_sidebar-layout/settings/project/mcp-endpoint' | '/_sidebar-layout/settings/project/mcp-servers' | '/_sidebar-layout/settings/project/models' | '/_sidebar-layout/settings/project/slack' @@ -431,6 +469,7 @@ export interface FileRouteTypes { | '/_sidebar-layout/settings/project/telegram' | '/_sidebar-layout/settings/project/whatsapp' | '/_sidebar-layout/stories/shared/$shareId' + | '/_sidebar-layout/stories/standalone/$storyId' | '/_sidebar-layout/settings/project/' | '/_sidebar-layout/stories/preview/$chatId/$storySlug' fileRoutesById: FileRoutesById @@ -550,6 +589,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SidebarLayoutSettingsMemoryRouteImport parentRoute: typeof SidebarLayoutSettingsRoute } + '/_sidebar-layout/settings/mcp-endpoint': { + id: '/_sidebar-layout/settings/mcp-endpoint' + path: '/mcp-endpoint' + fullPath: '/settings/mcp-endpoint' + preLoaderRoute: typeof SidebarLayoutSettingsMcpEndpointRouteImport + parentRoute: typeof SidebarLayoutSettingsRoute + } '/_sidebar-layout/settings/logs': { id: '/_sidebar-layout/settings/logs' path: '/logs' @@ -599,6 +645,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SidebarLayoutSettingsProjectIndexRouteImport parentRoute: typeof SidebarLayoutSettingsProjectRoute } + '/_sidebar-layout/stories/standalone/$storyId': { + id: '/_sidebar-layout/stories/standalone/$storyId' + path: '/stories/standalone/$storyId' + fullPath: '/stories/standalone/$storyId' + preLoaderRoute: typeof SidebarLayoutStoriesStandaloneStoryIdRouteImport + parentRoute: typeof SidebarLayoutRoute + } '/_sidebar-layout/stories/shared/$shareId': { id: '/_sidebar-layout/stories/shared/$shareId' path: '/stories/shared/$shareId' @@ -655,6 +708,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SidebarLayoutSettingsProjectMcpServersRouteImport parentRoute: typeof SidebarLayoutSettingsProjectRoute } + '/_sidebar-layout/settings/project/mcp-endpoint': { + id: '/_sidebar-layout/settings/project/mcp-endpoint' + path: '/mcp-endpoint' + fullPath: '/settings/project/mcp-endpoint' + preLoaderRoute: typeof SidebarLayoutSettingsProjectMcpEndpointRouteImport + parentRoute: typeof SidebarLayoutSettingsProjectRoute + } '/_sidebar-layout/settings/project/budgets': { id: '/_sidebar-layout/settings/project/budgets' path: '/budgets' @@ -698,6 +758,7 @@ const SidebarLayoutChatLayoutRouteWithChildren = interface SidebarLayoutSettingsProjectRouteChildren { SidebarLayoutSettingsProjectAgentRoute: typeof SidebarLayoutSettingsProjectAgentRoute SidebarLayoutSettingsProjectBudgetsRoute: typeof SidebarLayoutSettingsProjectBudgetsRoute + SidebarLayoutSettingsProjectMcpEndpointRoute: typeof SidebarLayoutSettingsProjectMcpEndpointRoute SidebarLayoutSettingsProjectMcpServersRoute: typeof SidebarLayoutSettingsProjectMcpServersRoute SidebarLayoutSettingsProjectModelsRoute: typeof SidebarLayoutSettingsProjectModelsRoute SidebarLayoutSettingsProjectSlackRoute: typeof SidebarLayoutSettingsProjectSlackRoute @@ -714,6 +775,8 @@ const SidebarLayoutSettingsProjectRouteChildren: SidebarLayoutSettingsProjectRou SidebarLayoutSettingsProjectAgentRoute, SidebarLayoutSettingsProjectBudgetsRoute: SidebarLayoutSettingsProjectBudgetsRoute, + SidebarLayoutSettingsProjectMcpEndpointRoute: + SidebarLayoutSettingsProjectMcpEndpointRoute, SidebarLayoutSettingsProjectMcpServersRoute: SidebarLayoutSettingsProjectMcpServersRoute, SidebarLayoutSettingsProjectModelsRoute: @@ -743,6 +806,7 @@ interface SidebarLayoutSettingsRouteChildren { SidebarLayoutSettingsContextExplorerRoute: typeof SidebarLayoutSettingsContextExplorerRoute SidebarLayoutSettingsEnterpriseRoute: typeof SidebarLayoutSettingsEnterpriseRoute SidebarLayoutSettingsLogsRoute: typeof SidebarLayoutSettingsLogsRoute + SidebarLayoutSettingsMcpEndpointRoute: typeof SidebarLayoutSettingsMcpEndpointRoute SidebarLayoutSettingsMemoryRoute: typeof SidebarLayoutSettingsMemoryRoute SidebarLayoutSettingsOrganizationRoute: typeof SidebarLayoutSettingsOrganizationRoute SidebarLayoutSettingsProjectRoute: typeof SidebarLayoutSettingsProjectRouteWithChildren @@ -757,6 +821,7 @@ const SidebarLayoutSettingsRouteChildren: SidebarLayoutSettingsRouteChildren = { SidebarLayoutSettingsContextExplorerRoute, SidebarLayoutSettingsEnterpriseRoute: SidebarLayoutSettingsEnterpriseRoute, SidebarLayoutSettingsLogsRoute: SidebarLayoutSettingsLogsRoute, + SidebarLayoutSettingsMcpEndpointRoute: SidebarLayoutSettingsMcpEndpointRoute, SidebarLayoutSettingsMemoryRoute: SidebarLayoutSettingsMemoryRoute, SidebarLayoutSettingsOrganizationRoute: SidebarLayoutSettingsOrganizationRoute, @@ -777,6 +842,7 @@ interface SidebarLayoutRouteChildren { SidebarLayoutSharedChatShareIdRoute: typeof SidebarLayoutSharedChatShareIdRoute SidebarLayoutStoriesIndexRoute: typeof SidebarLayoutStoriesIndexRoute SidebarLayoutStoriesSharedShareIdRoute: typeof SidebarLayoutStoriesSharedShareIdRoute + SidebarLayoutStoriesStandaloneStoryIdRoute: typeof SidebarLayoutStoriesStandaloneStoryIdRoute SidebarLayoutStoriesPreviewChatIdStorySlugRoute: typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } @@ -787,6 +853,8 @@ const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = { SidebarLayoutStoriesIndexRoute: SidebarLayoutStoriesIndexRoute, SidebarLayoutStoriesSharedShareIdRoute: SidebarLayoutStoriesSharedShareIdRoute, + SidebarLayoutStoriesStandaloneStoryIdRoute: + SidebarLayoutStoriesStandaloneStoryIdRoute, SidebarLayoutStoriesPreviewChatIdStorySlugRoute: SidebarLayoutStoriesPreviewChatIdStorySlugRoute, } diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx new file mode 100644 index 000000000..aa91a3472 --- /dev/null +++ b/apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { McpEndpointSettings } from '@/components/settings/mcp-endpoint'; +import { SettingsPageWrapper } from '@/components/ui/settings-card'; +import { trpc } from '@/main'; + +export const Route = createFileRoute('/_sidebar-layout/settings/mcp-endpoint')({ + component: McpEndpointPage, +}); + +function McpEndpointPage() { + const project = useQuery(trpc.project.getCurrent.queryOptions()); + const allProjects = useQuery(trpc.project.listForCurrentUser.queryOptions()); + const isAdmin = project.data?.userRole === 'admin'; + + return ( + + + + ); +} diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx new file mode 100644 index 000000000..5e68c7789 --- /dev/null +++ b/apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { McpEndpointSettings } from '@/components/settings/mcp-endpoint'; +import { trpc } from '@/main'; + +export const Route = createFileRoute('/_sidebar-layout/settings/project/mcp-endpoint')({ + component: ProjectMcpEndpointPage, +}); + +function ProjectMcpEndpointPage() { + const project = useQuery(trpc.project.getCurrent.queryOptions()); + const isAdmin = project.data?.userRole === 'admin'; + + return ; +} diff --git a/apps/frontend/src/routes/_sidebar-layout.stories.index.tsx b/apps/frontend/src/routes/_sidebar-layout.stories.index.tsx index e9278fb16..7fc53864f 100644 --- a/apps/frontend/src/routes/_sidebar-layout.stories.index.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.stories.index.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { createFileRoute } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; -import type { DisplayMode, GroupBy } from '@/lib/stories-page'; +import type { DisplayMode, GroupBy, OwnStoryListItem } from '@/lib/stories-page'; import { StoriesEmptyState, StoriesGroups, StoriesNoResults } from '@/components/stories-groups'; import { StoriesToolbarControls } from '@/components/stories-toolbar-controls'; import { MobileHeader } from '@/components/mobile-header'; @@ -32,19 +32,46 @@ function StoriesPage() { const [showArchived, setShowArchived] = useState(false); const userStories = useQuery(trpc.story.listAll.queryOptions()); + const standaloneStories = useQuery(trpc.story.listStandalone.queryOptions()); const sharedStories = useQuery(trpc.storyShare.list.queryOptions()); const archivedStories = useQuery({ ...trpc.story.listArchived.queryOptions(), enabled: showArchived, }); + const archivedStandaloneStories = useQuery({ + ...trpc.story.listStandaloneArchived.queryOptions(), + enabled: showArchived, + }); const currentUserName = session?.user?.name ?? 'Me'; const currentUserId = session?.user?.id; const allItems = useMemo(() => { + const mapStandalone = ( + stories: + | { + id: string; + storySlug: string; + title: string; + createdAt: Date; + summary: OwnStoryListItem['summary']; + }[] + | undefined, + ): OwnStoryListItem[] | undefined => + stories?.map((s) => ({ + id: s.id, + chatId: null, + storySlug: s.storySlug, + title: s.title, + createdAt: s.createdAt, + summary: s.summary, + isStandalone: true, + })); + if (showArchived) { return buildStoryItems({ userStories: archivedStories.data ?? [], + standaloneStories: mapStandalone(archivedStandaloneStories.data), sharedStories: [], currentUserId, currentUserName, @@ -52,11 +79,21 @@ function StoriesPage() { } return buildStoryItems({ userStories: userStories.data ?? [], + standaloneStories: mapStandalone(standaloneStories.data), sharedStories: sharedStories.data ?? [], currentUserId, currentUserName, }); - }, [showArchived, userStories.data, sharedStories.data, archivedStories.data, currentUserId, currentUserName]); + }, [ + showArchived, + userStories.data, + standaloneStories.data, + sharedStories.data, + archivedStories.data, + archivedStandaloneStories.data, + currentUserId, + currentUserName, + ]); const filteredItems = useMemo(() => { return filterStories(allItems, searchQuery); @@ -64,7 +101,9 @@ function StoriesPage() { const groups = useMemo(() => groupStories(filteredItems, groupBy), [filteredItems, groupBy]); - const isLoading = showArchived ? archivedStories.isLoading : userStories.isLoading || sharedStories.isLoading; + const isLoading = showArchived + ? archivedStories.isLoading || archivedStandaloneStories.isLoading + : userStories.isLoading || standaloneStories.isLoading || sharedStories.isLoading; const isEmpty = allItems.length === 0 && !isLoading; function handleDisplayChange(mode: DisplayMode) { diff --git a/apps/frontend/src/routes/_sidebar-layout.stories.shared.$shareId.tsx b/apps/frontend/src/routes/_sidebar-layout.stories.shared.$shareId.tsx index ce471c1a9..12df40c14 100644 --- a/apps/frontend/src/routes/_sidebar-layout.stories.shared.$shareId.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.stories.shared.$shareId.tsx @@ -121,8 +121,13 @@ function SharedStoryPage() { )}
- - {isOwner ? ( + + {isOwner && story.chatId ? ( + ) : isOwner ? ( + ) : ( !isViewer && (
diff --git a/apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx b/apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx new file mode 100644 index 000000000..03c6ae245 --- /dev/null +++ b/apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx @@ -0,0 +1,109 @@ +import { splitCodeIntoSegments } from '@nao/shared/story-segments'; +import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { MessageSquare, Loader2 } from 'lucide-react'; +import { useCallback, useMemo } from 'react'; +import type { ParsedChartBlock, ParsedTableBlock } from '@nao/shared/story-segments'; + +import type { SelectionData } from '@/components/highlight-bubble'; +import type { QueryDataMap } from '@/components/story-embeds'; +import { HighlightBubble } from '@/components/highlight-bubble'; +import { StoryDownload } from '@/components/story-download'; +import { StoryChartEmbed, StoryTableEmbed } from '@/components/story-embeds'; +import { SegmentList } from '@/components/story-rendering'; +import { Button } from '@/components/ui/button'; +import { SelectionProvider } from '@/contexts/text-selection'; +import { chatPendingCitationStore } from '@/stores/chat-pending-citation'; +import { trpc } from '@/main'; + +export const Route = createFileRoute('/_sidebar-layout/stories/standalone/$storyId')({ + component: StandaloneStoryPage, +}); + +function StandaloneStoryPage() { + const { storyId } = Route.useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data: story } = useSuspenseQuery(trpc.story.getStandalone.queryOptions({ storyId })); + + const openStandaloneMutation = useMutation( + trpc.chatFork.openStandalone.mutationOptions({ + onSuccess: ({ chatId }) => { + queryClient.invalidateQueries({ queryKey: trpc.story.listAll.queryKey() }); + queryClient.invalidateQueries({ queryKey: trpc.story.listStandalone.queryKey() }); + navigate({ to: '/$chatId', params: { chatId }, state: { openStorySlug: story.slug } }); + }, + }), + ); + + const handleSelectionAsk = useCallback( + (data: SelectionData) => { + if (!story.chatId) { + return; + } + chatPendingCitationStore.set({ chatId: story.chatId, storySlug: story.slug, ...data }); + navigate({ to: '/$chatId', params: { chatId: story.chatId } }); + }, + [navigate, story.chatId, story.slug], + ); + + const handleOpenChat = useCallback(() => { + if (story.chatId) { + navigate({ to: '/$chatId', params: { chatId: story.chatId }, state: { openStorySlug: story.slug } }); + } else { + openStandaloneMutation.mutate({ storyId }); + } + }, [story.chatId, story.slug, storyId, navigate, openStandaloneMutation]); + + return ( +
+
+

{story.title}

+
+ + +
+
+ + + + +
+ ); +} + +function StandaloneStoryContent({ code, queryData }: { code: string; queryData: QueryDataMap | null }) { + const segments = useMemo(() => splitCodeIntoSegments(code), [code]); + + const renderChart = useCallback( + (chart: ParsedChartBlock) => , + [queryData], + ); + + const renderTable = useCallback( + (table: ParsedTableBlock) => , + [queryData], + ); + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/frontend/src/routes/login.tsx b/apps/frontend/src/routes/login.tsx index 57c497697..125b75abe 100644 --- a/apps/frontend/src/routes/login.tsx +++ b/apps/frontend/src/routes/login.tsx @@ -13,6 +13,14 @@ export const Route = createFileRoute('/login')({ component: Login, }); +function buildMcpAuthorizeUrl() { + const params = new URLSearchParams(window.location.search); + if (!params.has('client_id')) { + return null; + } + return `/api/auth/mcp/authorize${window.location.search}`; +} + function Login() { const navigate = useNavigate(); const { error: oauthError } = Route.useSearch(); @@ -21,12 +29,20 @@ function Login() { const config = useQuery(trpc.system.getPublicConfig.queryOptions()); const isCloud = config.data?.naoMode === 'cloud'; + const mcpAuthorizeUrl = buildMcpAuthorizeUrl(); + const form = useForm({ defaultValues: { email: '', password: '' }, onSubmit: async ({ value }) => { setServerError(undefined); await signIn.email(value, { - onSuccess: () => navigate({ to: '/' }), + onSuccess: () => { + if (mcpAuthorizeUrl) { + window.location.href = mcpAuthorizeUrl; + } else { + navigate({ to: '/' }); + } + }, onError: (err) => setServerError(err.error.message), }); }, @@ -39,6 +55,7 @@ function Login() { submitText='Log In' serverError={serverError} displaySocialProviders={true} + socialCallbackUrl={mcpAuthorizeUrl ?? undefined} footer={ isCloud ? ( <> diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 8dd41e6d5..9769a02bb 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -36,6 +36,12 @@ export default defineConfig({ '/api': { target: 'http://localhost:5005', }, + '/mcp': { + target: 'http://localhost:5005', + }, + '/.well-known': { + target: 'http://localhost:5005', + }, '/i/': { target: 'http://localhost:5005', }, diff --git a/package-lock.json b/package-lock.json index d6e4c4a84..b4941cde5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^8.1.0", "@microsoft/microsoft-graph-client": "^3.0.7", + "@modelcontextprotocol/sdk": "^1.29.0", "@nao/shared": "*", "@openrouter/ai-sdk-provider": "^2.2.3", "@pydantic/monty": "^0.0.7", From 8dd970513b2803fa5df6d81787be2df7fddbc1e1 Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Sun, 3 May 2026 19:08:07 +0200 Subject: [PATCH 2/7] add the hook permission to handle the MCP settings --- apps/backend/src/mcp/tools/data.ts | 6 ++++++ apps/backend/src/mcp/tools/stories.ts | 2 +- apps/frontend/src/components/sidebar-settings-nav.tsx | 1 + .../_sidebar-layout.settings.project.mcp-endpoint.tsx | 8 ++++---- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/mcp/tools/data.ts b/apps/backend/src/mcp/tools/data.ts index a088095c2..cc5360f08 100644 --- a/apps/backend/src/mcp/tools/data.ts +++ b/apps/backend/src/mcp/tools/data.ts @@ -5,6 +5,8 @@ import { z } from 'zod'; import { executeQuery } from '../../agents/tools/execute-sql'; import { getEnvVars, retrieveProjectById } from '../../queries/project.queries'; +import { hasFeature, LICENSE_FEATURES } from '../../services/license.service'; +import { getAzureAccessTokenForUser } from '../../services/microsoft-auth.service'; import { logger } from '../../utils/logger'; import type { McpContext } from '../logging'; import { withLogging } from '../logging'; @@ -25,6 +27,9 @@ export function registerDataTools(server: McpServer, ctx: McpContext): void { try { const project = await retrieveProjectById(ctx.projectId); const envVars = await getEnvVars(ctx.projectId); + const azureAccessToken = (await hasFeature(LICENSE_FEATURES.sso)) + ? await getAzureAccessTokenForUser(ctx.userId) + : null; const cappedLimit = Math.min(limit, 1000); const result = await executeQuery( @@ -34,6 +39,7 @@ export function registerDataTools(server: McpServer, ctx: McpContext): void { chatId: '', agentSettings: null, envVars, + azureAccessToken, queryResults: new Map(), }, ); diff --git a/apps/backend/src/mcp/tools/stories.ts b/apps/backend/src/mcp/tools/stories.ts index 60b95f051..b76e05139 100644 --- a/apps/backend/src/mcp/tools/stories.ts +++ b/apps/backend/src/mcp/tools/stories.ts @@ -290,7 +290,7 @@ async function saveNewVersion( code: string, ): Promise<{ id: string; title: string; updatedAt: Date }> { if (story.chatId) { - await storyQueries.createVersion({ + await storyQueries.createStoryVersion({ chatId: story.chatId, slug: story.slug, title, diff --git a/apps/frontend/src/components/sidebar-settings-nav.tsx b/apps/frontend/src/components/sidebar-settings-nav.tsx index 511d969b8..c1d68b114 100644 --- a/apps/frontend/src/components/sidebar-settings-nav.tsx +++ b/apps/frontend/src/components/sidebar-settings-nav.tsx @@ -49,6 +49,7 @@ const settingsNavItems: NavItem[] = [ { label: 'MCP Endpoint', to: '/settings/mcp-endpoint', + visible: ({ isViewer }) => !isViewer, }, { label: 'Observability', diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx index 5e68c7789..1aae58d3f 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.project.mcp-endpoint.tsx @@ -1,15 +1,15 @@ import { createFileRoute } from '@tanstack/react-router'; -import { useQuery } from '@tanstack/react-query'; import { McpEndpointSettings } from '@/components/settings/mcp-endpoint'; -import { trpc } from '@/main'; +import { usePermissions } from '@/hooks/use-permissions'; +import { requireNonViewer } from '@/lib/require-admin'; export const Route = createFileRoute('/_sidebar-layout/settings/project/mcp-endpoint')({ + beforeLoad: requireNonViewer, component: ProjectMcpEndpointPage, }); function ProjectMcpEndpointPage() { - const project = useQuery(trpc.project.getCurrent.queryOptions()); - const isAdmin = project.data?.userRole === 'admin'; + const { isAdmin } = usePermissions(); return ; } From 006fa83ea2b3666b5eff0f3130e898463e365286 Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Mon, 4 May 2026 10:09:07 +0200 Subject: [PATCH 3/7] adapt new permissions hook to the PR --- .../src/routes/_sidebar-layout.settings.mcp-endpoint.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx index aa91a3472..4de4fd5be 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.mcp-endpoint.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; import { McpEndpointSettings } from '@/components/settings/mcp-endpoint'; import { SettingsPageWrapper } from '@/components/ui/settings-card'; +import { usePermissions } from '@/hooks/use-permissions'; import { trpc } from '@/main'; export const Route = createFileRoute('/_sidebar-layout/settings/mcp-endpoint')({ @@ -9,9 +10,8 @@ export const Route = createFileRoute('/_sidebar-layout/settings/mcp-endpoint')({ }); function McpEndpointPage() { - const project = useQuery(trpc.project.getCurrent.queryOptions()); + const { isAdmin } = usePermissions(); const allProjects = useQuery(trpc.project.listForCurrentUser.queryOptions()); - const isAdmin = project.data?.userRole === 'admin'; return ( From eb7d79607271e13a93b1886c90a6907074aa9627 Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Mon, 4 May 2026 12:34:37 +0200 Subject: [PATCH 4/7] handle fixes from cubic review and real-time update MCP settings on MCP client side too --- .../migrations-postgres/0038_mcp_endpoint.sql | 3 + .../meta/0038_snapshot.json | 60 +++++++++++++++++- .../migrations-postgres/meta/_journal.json | 2 +- .../migrations-sqlite/0038_mcp_endpoint.sql | 3 + .../migrations-sqlite/meta/0038_snapshot.json | 26 +++++++- .../migrations-sqlite/meta/_journal.json | 2 +- apps/backend/src/db/pg-schema.ts | 11 +++- apps/backend/src/db/sqlite-schema.ts | 12 +++- apps/backend/src/mcp/logging.ts | 4 ++ apps/backend/src/mcp/routes.ts | 13 ++-- apps/backend/src/mcp/tools/agent.ts | 5 +- apps/backend/src/mcp/tools/data.ts | 13 ++-- apps/backend/src/mcp/tools/files.ts | 2 +- apps/backend/src/mcp/tools/stories.ts | 15 ++++- apps/backend/src/queries/story.queries.ts | 61 ++++++++++++++----- apps/backend/src/trpc/chat-fork.routes.ts | 2 +- apps/backend/src/trpc/mcp-endpoint.routes.ts | 5 +- apps/backend/src/trpc/story.routes.ts | 29 +++------ apps/frontend/src/components/auth-form.tsx | 2 +- .../src/components/auth-microsoft-button.tsx | 13 +++- apps/frontend/src/lib/microsoft-auth.ts | 4 +- 21 files changed, 227 insertions(+), 60 deletions(-) diff --git a/apps/backend/migrations-postgres/0038_mcp_endpoint.sql b/apps/backend/migrations-postgres/0038_mcp_endpoint.sql index 1c4c60d43..f50d570c5 100644 --- a/apps/backend/migrations-postgres/0038_mcp_endpoint.sql +++ b/apps/backend/migrations-postgres/0038_mcp_endpoint.sql @@ -64,6 +64,7 @@ ALTER TABLE "oauth_application" ADD CONSTRAINT "oauth_application_user_id_user_i ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_client_id_oauth_application_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_application"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE INDEX "mcp_call_log_projectId_idx" ON "mcp_call_log" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "mcp_call_log_userId_idx" ON "mcp_call_log" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "mcp_call_log_calledAt_idx" ON "mcp_call_log" USING btree ("called_at");--> statement-breakpoint CREATE INDEX "oauth_access_token_clientId_idx" ON "oauth_access_token" USING btree ("client_id");--> statement-breakpoint CREATE INDEX "oauth_access_token_userId_idx" ON "oauth_access_token" USING btree ("user_id");--> statement-breakpoint @@ -72,4 +73,6 @@ CREATE INDEX "oauth_consent_clientId_idx" ON "oauth_consent" USING btree ("clien CREATE INDEX "oauth_consent_userId_idx" ON "oauth_consent" USING btree ("user_id");--> statement-breakpoint ALTER TABLE "story" ADD CONSTRAINT "story_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "story" ADD CONSTRAINT "story_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "story_standalone_slug_unique" ON "story" USING btree ("project_id","user_id","slug") WHERE "story"."chat_id" IS NULL;--> statement-breakpoint +CREATE INDEX "story_projectId_idx" ON "story" USING btree ("project_id");--> statement-breakpoint CREATE INDEX "story_userId_idx" ON "story" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/backend/migrations-postgres/meta/0038_snapshot.json b/apps/backend/migrations-postgres/meta/0038_snapshot.json index ecbea087c..d11b82fc6 100644 --- a/apps/backend/migrations-postgres/meta/0038_snapshot.json +++ b/apps/backend/migrations-postgres/meta/0038_snapshot.json @@ -1,5 +1,5 @@ { - "id": "96465050-5ae4-41a6-a8e5-eeac821b6daf", + "id": "5a215008-f5a2-4a6c-b052-e72b9cefa90e", "prevId": "82faa6ea-95ce-444e-97f6-5649a163bd4d", "version": "7", "dialect": "postgresql", @@ -1019,6 +1019,21 @@ "method": "btree", "with": {} }, + "mcp_call_log_userId_idx": { + "name": "mcp_call_log_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "mcp_call_log_calledAt_idx": { "name": "mcp_call_log_calledAt_idx", "columns": [ @@ -3261,6 +3276,34 @@ } }, "indexes": { + "story_standalone_slug_unique": { + "name": "story_standalone_slug_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"story\".\"chat_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, "story_chatId_idx": { "name": "story_chatId_idx", "columns": [ @@ -3276,6 +3319,21 @@ "method": "btree", "with": {} }, + "story_projectId_idx": { + "name": "story_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "story_userId_idx": { "name": "story_userId_idx", "columns": [ diff --git a/apps/backend/migrations-postgres/meta/_journal.json b/apps/backend/migrations-postgres/meta/_journal.json index 0daa3c876..8a4d19974 100644 --- a/apps/backend/migrations-postgres/meta/_journal.json +++ b/apps/backend/migrations-postgres/meta/_journal.json @@ -271,7 +271,7 @@ { "idx": 38, "version": "7", - "when": 1777826483372, + "when": 1777888607991, "tag": "0038_mcp_endpoint", "breakpoints": true } diff --git a/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql b/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql index 33213eb5f..5a6fc979e 100644 --- a/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql +++ b/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql @@ -13,6 +13,7 @@ CREATE TABLE `mcp_call_log` ( ); --> statement-breakpoint CREATE INDEX `mcp_call_log_projectId_idx` ON `mcp_call_log` (`project_id`);--> statement-breakpoint +CREATE INDEX `mcp_call_log_userId_idx` ON `mcp_call_log` (`user_id`);--> statement-breakpoint CREATE INDEX `mcp_call_log_calledAt_idx` ON `mcp_call_log` (`called_at`);--> statement-breakpoint CREATE TABLE `oauth_access_token` ( `id` text PRIMARY KEY NOT NULL, @@ -90,7 +91,9 @@ INSERT INTO `__new_story`("id", "chat_id", "project_id", "user_id", "slug", "tit DROP TABLE `story`;--> statement-breakpoint ALTER TABLE `__new_story` RENAME TO `story`;--> statement-breakpoint PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `story_standalone_slug_unique` ON `story` (`project_id`,`user_id`,`slug`) WHERE chat_id IS NULL;--> statement-breakpoint CREATE INDEX `story_chatId_idx` ON `story` (`chat_id`);--> statement-breakpoint +CREATE INDEX `story_projectId_idx` ON `story` (`project_id`);--> statement-breakpoint CREATE INDEX `story_userId_idx` ON `story` (`user_id`);--> statement-breakpoint CREATE UNIQUE INDEX `story_chat_slug_unique` ON `story` (`chat_id`,`slug`);--> statement-breakpoint ALTER TABLE `project` ADD `mcp_endpoint_settings` text; \ No newline at end of file diff --git a/apps/backend/migrations-sqlite/meta/0038_snapshot.json b/apps/backend/migrations-sqlite/meta/0038_snapshot.json index ab485bd25..f474b5b22 100644 --- a/apps/backend/migrations-sqlite/meta/0038_snapshot.json +++ b/apps/backend/migrations-sqlite/meta/0038_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "d9f4c3b0-4e14-4adb-8670-ef8e7b94c20a", + "id": "1726b1b6-8ef5-4f15-b484-62c3eff4eb3e", "prevId": "fd9181fb-f275-49cd-8546-6d7cb0e5c0be", "tables": { "account": { @@ -949,6 +949,13 @@ ], "isUnique": false }, + "mcp_call_log_userId_idx": { + "name": "mcp_call_log_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, "mcp_call_log_calledAt_idx": { "name": "mcp_call_log_calledAt_idx", "columns": [ @@ -3114,6 +3121,16 @@ } }, "indexes": { + "story_standalone_slug_unique": { + "name": "story_standalone_slug_unique", + "columns": [ + "project_id", + "user_id", + "slug" + ], + "isUnique": true, + "where": "chat_id IS NULL" + }, "story_chatId_idx": { "name": "story_chatId_idx", "columns": [ @@ -3121,6 +3138,13 @@ ], "isUnique": false }, + "story_projectId_idx": { + "name": "story_projectId_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, "story_userId_idx": { "name": "story_userId_idx", "columns": [ diff --git a/apps/backend/migrations-sqlite/meta/_journal.json b/apps/backend/migrations-sqlite/meta/_journal.json index 686189e0e..61349a454 100644 --- a/apps/backend/migrations-sqlite/meta/_journal.json +++ b/apps/backend/migrations-sqlite/meta/_journal.json @@ -271,7 +271,7 @@ { "idx": 38, "version": "6", - "when": 1777826473421, + "when": 1777888554345, "tag": "0038_mcp_endpoint", "breakpoints": true } diff --git a/apps/backend/src/db/pg-schema.ts b/apps/backend/src/db/pg-schema.ts index 4b78c5364..bdb9e4a92 100644 --- a/apps/backend/src/db/pg-schema.ts +++ b/apps/backend/src/db/pg-schema.ts @@ -13,6 +13,7 @@ import { text, timestamp, unique, + uniqueIndex, } from 'drizzle-orm/pg-core'; import { AgentSettings } from '../types/agent-settings'; @@ -512,7 +513,11 @@ export const story = pgTable( }, (t) => [ unique('story_chat_slug_unique').on(t.chatId, t.slug), + uniqueIndex('story_standalone_slug_unique') + .on(t.projectId, t.userId, t.slug) + .where(sql`${t.chatId} IS NULL`), index('story_chatId_idx').on(t.chatId), + index('story_projectId_idx').on(t.projectId), index('story_userId_idx').on(t.userId), ], ); @@ -688,7 +693,11 @@ export const mcpCallLog = pgTable( toolOutput: jsonb('tool_output').$type(), calledAt: timestamp('called_at').defaultNow().notNull(), }, - (t) => [index('mcp_call_log_projectId_idx').on(t.projectId), index('mcp_call_log_calledAt_idx').on(t.calledAt)], + (t) => [ + index('mcp_call_log_projectId_idx').on(t.projectId), + index('mcp_call_log_userId_idx').on(t.userId), + index('mcp_call_log_calledAt_idx').on(t.calledAt), + ], ); export const oauthApplication = pgTable( diff --git a/apps/backend/src/db/sqlite-schema.ts b/apps/backend/src/db/sqlite-schema.ts index 211e42979..a9a13621a 100644 --- a/apps/backend/src/db/sqlite-schema.ts +++ b/apps/backend/src/db/sqlite-schema.ts @@ -2,7 +2,7 @@ import type { CitationData, LlmProvider } from '@nao/shared/types'; import { BUDGET_PERIODS, SHARE_VISIBILITY, USER_ROLES } from '@nao/shared/types'; import { type ProviderMetadata } from 'ai'; import { sql } from 'drizzle-orm'; -import { check, index, integer, primaryKey, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core'; +import { check, index, integer, primaryKey, sqliteTable, text, unique, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { AgentSettings } from '../types/agent-settings'; import { ForkMetadata, StopReason, ToolState, UIMessagePartType } from '../types/chat'; @@ -540,7 +540,11 @@ export const story = sqliteTable( }, (t) => [ unique('story_chat_slug_unique').on(t.chatId, t.slug), + uniqueIndex('story_standalone_slug_unique') + .on(t.projectId, t.userId, t.slug) + .where(sql`chat_id IS NULL`), index('story_chatId_idx').on(t.chatId), + index('story_projectId_idx').on(t.projectId), index('story_userId_idx').on(t.userId), ], ); @@ -736,7 +740,11 @@ export const mcpCallLog = sqliteTable( .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), }, - (t) => [index('mcp_call_log_projectId_idx').on(t.projectId), index('mcp_call_log_calledAt_idx').on(t.calledAt)], + (t) => [ + index('mcp_call_log_projectId_idx').on(t.projectId), + index('mcp_call_log_userId_idx').on(t.userId), + index('mcp_call_log_calledAt_idx').on(t.calledAt), + ], ); export const oauthApplication = sqliteTable( diff --git a/apps/backend/src/mcp/logging.ts b/apps/backend/src/mcp/logging.ts index 02df728c4..e8eb7f3fc 100644 --- a/apps/backend/src/mcp/logging.ts +++ b/apps/backend/src/mcp/logging.ts @@ -24,6 +24,7 @@ export type ToolHandler = (args: T, extra: ToolExtra) => Promise; export const TOOL_MODE_MAP: Record = { ask_nao: 'agentModeEnabled', execute_sql: 'toolsModeEnabled', + build_chart: 'toolsModeEnabled', grep: 'toolsModeEnabled', ls: 'toolsModeEnabled', list_stories: 'objectsModeEnabled', @@ -49,6 +50,9 @@ export function withLogging(toolName: string, ctx: McpContext, handler: ToolH let result: ToolResult | undefined; try { result = await handler(args, extra); + if (result?.isError) { + success = false; + } return result; } catch (error) { success = false; diff --git a/apps/backend/src/mcp/routes.ts b/apps/backend/src/mcp/routes.ts index fa7d62594..94059a4db 100644 --- a/apps/backend/src/mcp/routes.ts +++ b/apps/backend/src/mcp/routes.ts @@ -26,7 +26,7 @@ export const mcpServerRoutes = async (app: App) => { const existingSessionId = request.headers['mcp-session-id'] as string | undefined; if (existingSessionId) { const session = sessions.get(existingSessionId); - if (session) { + if (session && session.userId === userId) { session.lastAccess = Date.now(); await session.transport.handleRequest(request.raw, reply.raw, request.body as Record); reply.hijack(); @@ -46,7 +46,7 @@ export const mcpServerRoutes = async (app: App) => { sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, onsessioninitialized: (sessionId) => { - sessions.set(sessionId, { transport, server, userId, lastAccess: Date.now() }); + sessions.set(sessionId, { transport, server, userId, projectId, lastAccess: Date.now() }); }, onsessionclosed: (sessionId) => { sessions.delete(sessionId); @@ -71,7 +71,7 @@ export const mcpServerRoutes = async (app: App) => { } const session = sessions.get(sessionId); - if (!session) { + if (!session || session.userId !== userId) { return reply.status(404).send({ error: 'Session not found or expired.' }); } @@ -81,9 +81,14 @@ export const mcpServerRoutes = async (app: App) => { }); app.delete('/', async (request, reply) => { + const userId = await resolveUserId(request); + if (!userId) { + return replyUnauthorized(request, reply); + } + const sessionId = request.headers['mcp-session-id'] as string | undefined; const session = sessionId ? sessions.get(sessionId) : undefined; - if (!session) { + if (!session || session.userId !== userId) { return reply.status(400).send({ error: 'Invalid or missing session.' }); } diff --git a/apps/backend/src/mcp/tools/agent.ts b/apps/backend/src/mcp/tools/agent.ts index 71446eb17..107057e6e 100644 --- a/apps/backend/src/mcp/tools/agent.ts +++ b/apps/backend/src/mcp/tools/agent.ts @@ -64,7 +64,10 @@ export function registerAgentTools(server: McpServer, ctx: McpContext): void { source: 'tool', context: { question, userId: ctx.userId }, }); - return { content: [{ type: 'text' as const, text: `Nao agent error: ${message}` }], isError: true }; + return { + content: [{ type: 'text' as const, text: 'Nao agent failed to process the request.' }], + isError: true, + }; } }), ); diff --git a/apps/backend/src/mcp/tools/data.ts b/apps/backend/src/mcp/tools/data.ts index cc5360f08..8f749f307 100644 --- a/apps/backend/src/mcp/tools/data.ts +++ b/apps/backend/src/mcp/tools/data.ts @@ -20,7 +20,14 @@ export function registerDataTools(server: McpServer, ctx: McpContext): void { 'Run a SQL query against the connected data warehouse. Returns rows as JSON. The response includes a `query_id` — pass it to `build_chart` or reference it in story `` blocks. Use ask_nao instead if you want Nao to write the SQL for you.', inputSchema: { sql: z.string().describe('The SQL query to execute'), - limit: z.number().optional().default(100).describe('Max rows to return (default 100, max 1000)'), + limit: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .default(100) + .describe('Max rows to return (default 100, min 1, max 1000)'), }, }, withLogging('execute_sql', ctx, async ({ sql, limit }) => { @@ -30,8 +37,6 @@ export function registerDataTools(server: McpServer, ctx: McpContext): void { const azureAccessToken = (await hasFeature(LICENSE_FEATURES.sso)) ? await getAzureAccessTokenForUser(ctx.userId) : null; - const cappedLimit = Math.min(limit, 1000); - const result = await executeQuery( { sql_query: sql }, { @@ -44,7 +49,7 @@ export function registerDataTools(server: McpServer, ctx: McpContext): void { }, ); - const rows = result.data.slice(0, cappedLimit); + const rows = result.data.slice(0, limit); const queryId = `query_${crypto.randomUUID().slice(0, 8)}`; const output = { query_id: queryId, columns: result.columns, row_count: rows.length, data: rows }; return { diff --git a/apps/backend/src/mcp/tools/files.ts b/apps/backend/src/mcp/tools/files.ts index beaea0279..2f4023b06 100644 --- a/apps/backend/src/mcp/tools/files.ts +++ b/apps/backend/src/mcp/tools/files.ts @@ -42,7 +42,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext): void { glob, case_insensitive, context_lines, - max_results: Math.min(max_results ?? 100, 500), + max_results: Math.min(Math.max(Math.floor(max_results ?? 100), 1), 500), }, makeExecutionOptions(project.path!), ); diff --git a/apps/backend/src/mcp/tools/stories.ts b/apps/backend/src/mcp/tools/stories.ts index b76e05139..9217778ae 100644 --- a/apps/backend/src/mcp/tools/stories.ts +++ b/apps/backend/src/mcp/tools/stories.ts @@ -109,7 +109,7 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { }, withLogging('create_story', ctx, async ({ title, content, query_data }) => { try { - const slug = generateSlug(title); + const slug = await getUniqueStandaloneSlug(ctx, title); const code = content ?? `# ${title}\n`; const version = await storyQueries.createStandaloneVersion({ @@ -260,7 +260,18 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { ); } -export function generateSlug(title: string): string { +async function getUniqueStandaloneSlug(ctx: McpContext, title: string): Promise { + const baseSlug = generateSlug(title); + let candidate = baseSlug; + let suffix = 2; + while (await storyQueries.getStandaloneStoryByUserAndSlug(ctx.userId, ctx.projectId, candidate)) { + candidate = `${baseSlug}-${suffix}`; + suffix += 1; + } + return candidate; +} + +function generateSlug(title: string): string { return ( title .toLowerCase() diff --git a/apps/backend/src/queries/story.queries.ts b/apps/backend/src/queries/story.queries.ts index 39727c9e9..5651dff82 100644 --- a/apps/backend/src/queries/story.queries.ts +++ b/apps/backend/src/queries/story.queries.ts @@ -31,6 +31,21 @@ export async function getStoryByChatAndSlug(chatId: string, slug: string): Promi return row ?? null; } +export async function getStoryOwnerId(storyId: string): Promise { + const [row] = await db + .select({ + storyUserId: s.story.userId, + chatUserId: s.chat.userId, + }) + .from(s.story) + .leftJoin(s.chat, eq(s.story.chatId, s.chat.id)) + .where(eq(s.story.id, storyId)) + .limit(1) + .execute(); + + return row?.chatUserId ?? row?.storyUserId ?? undefined; +} + export async function getStoryByIdForUser(storyId: string, userId: string): Promise { const latestVersions = latestVersionsSubquery(); @@ -188,21 +203,15 @@ export async function createStandaloneVersion(data: { action: 'create' | 'update' | 'replace'; source: 'assistant' | 'user'; }): Promise { - const existing = await getStandaloneStoryByUserAndSlug(data.userId, data.projectId, data.slug); + const story = await getOrCreateStandaloneStory({ + userId: data.userId, + projectId: data.projectId, + slug: data.slug, + title: data.title, + }); - let story: DBStory; - if (existing) { - story = existing; - if (story.title !== data.title) { - await db.update(s.story).set({ title: data.title }).where(eq(s.story.id, story.id)).execute(); - } - } else { - const [created] = await db - .insert(s.story) - .values({ projectId: data.projectId, userId: data.userId, slug: data.slug, title: data.title }) - .returning() - .execute(); - story = created; + if (story.title !== data.title) { + await db.update(s.story).set({ title: data.title }).where(eq(s.story.id, story.id)).execute(); } const nextVersion = db @@ -482,6 +491,30 @@ async function getOrCreateStory(data: { chatId: string; slug: string; title: str return row; } +async function getOrCreateStandaloneStory(data: { + userId: string; + projectId: string; + slug: string; + title: string; +}): Promise { + const existing = await getStandaloneStoryByUserAndSlug(data.userId, data.projectId, data.slug); + if (existing) { + return existing; + } + + await db + .insert(s.story) + .values({ projectId: data.projectId, userId: data.userId, slug: data.slug, title: data.title }) + .onConflictDoNothing() + .execute(); + + const row = await getStandaloneStoryByUserAndSlug(data.userId, data.projectId, data.slug); + if (!row) { + throw new Error(`Failed to create or retrieve standalone story: ${data.userId}/${data.projectId}/${data.slug}`); + } + return row; +} + async function getSqlQueriesByIds( chatId: string, queryIds: Set, diff --git a/apps/backend/src/trpc/chat-fork.routes.ts b/apps/backend/src/trpc/chat-fork.routes.ts index a24c3a69f..85951eca5 100644 --- a/apps/backend/src/trpc/chat-fork.routes.ts +++ b/apps/backend/src/trpc/chat-fork.routes.ts @@ -39,7 +39,7 @@ export const chatForkRoutes = { .input(z.object({ storyId: z.string() })) .mutation(async ({ input, ctx }): Promise<{ chatId: string }> => { const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); - if (!story) { + if (!story || story.projectId !== ctx.project.id) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); } if (story.chatId) { diff --git a/apps/backend/src/trpc/mcp-endpoint.routes.ts b/apps/backend/src/trpc/mcp-endpoint.routes.ts index e1791f7dd..94b6aeb0a 100644 --- a/apps/backend/src/trpc/mcp-endpoint.routes.ts +++ b/apps/backend/src/trpc/mcp-endpoint.routes.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod/v4'; +import { closeProjectSessions } from '../mcp/server'; import * as mcpEndpointQueries from '../queries/mcp-endpoint.queries'; import { adminProtectedProcedure, projectProtectedProcedure, protectedProcedure, router } from './trpc'; @@ -19,7 +20,9 @@ export const mcpEndpointRoutes = router({ }), ) .mutation(async ({ ctx, input }) => { - return mcpEndpointQueries.updateMcpEndpointSettings(ctx.project.id, input); + const updated = await mcpEndpointQueries.updateMcpEndpointSettings(ctx.project.id, input); + await closeProjectSessions(ctx.project.id); + return updated; }), getCallLogs: adminProtectedProcedure.query(async ({ ctx }) => { diff --git a/apps/backend/src/trpc/story.routes.ts b/apps/backend/src/trpc/story.routes.ts index 49e068bd5..96ffcb153 100644 --- a/apps/backend/src/trpc/story.routes.ts +++ b/apps/backend/src/trpc/story.routes.ts @@ -11,6 +11,7 @@ import { extractStorySummary } from '../utils/story-summary'; import { ownedResourceProcedure, projectProtectedProcedure, protectedProcedure } from './trpc'; const chatOwnerProcedure = ownedResourceProcedure(chatQueries.getChatOwnerId, 'chat'); +const storyOwnerProcedure = ownedResourceProcedure(storyQueries.getStoryOwnerId, 'story'); export const storyRoutes = { listAll: protectedProcedure.query(async ({ ctx }) => { @@ -49,7 +50,7 @@ export const storyRoutes = { })); }), - getStandalone: projectProtectedProcedure.input(z.object({ storyId: z.string() })).query(async ({ input, ctx }) => { + getStandalone: storyOwnerProcedure.input(z.object({ storyId: z.string() })).query(async ({ input, ctx }) => { const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); if (!story) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); @@ -181,25 +182,13 @@ export const storyRoutes = { await storyQueries.unarchiveStory(input.chatId, input.storySlug); }), - archiveStandalone: projectProtectedProcedure - .input(z.object({ storyId: z.string() })) - .mutation(async ({ input, ctx }) => { - const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); - if (!story) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); - } - await storyQueries.archiveByStoryId(story.id); - }), + archiveStandalone: storyOwnerProcedure.input(z.object({ storyId: z.string() })).mutation(async ({ input }) => { + await storyQueries.archiveByStoryId(input.storyId); + }), - unarchiveStandalone: projectProtectedProcedure - .input(z.object({ storyId: z.string() })) - .mutation(async ({ input, ctx }) => { - const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); - if (!story) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); - } - await storyQueries.unarchiveByStoryId(story.id); - }), + unarchiveStandalone: storyOwnerProcedure.input(z.object({ storyId: z.string() })).mutation(async ({ input }) => { + await storyQueries.unarchiveByStoryId(input.storyId); + }), archiveMany: protectedProcedure .input(z.object({ stories: z.array(z.object({ chatId: z.string(), storySlug: z.string() })).min(1) })) @@ -216,7 +205,7 @@ export const storyRoutes = { await storyQueries.archiveManyStories(input.stories.map((s) => ({ chatId: s.chatId, slug: s.storySlug }))); }), - downloadStandalone: projectProtectedProcedure + downloadStandalone: storyOwnerProcedure .input(z.object({ storyId: z.string(), format: z.enum(DOWNLOAD_FORMATS) })) .query(async ({ input, ctx }) => { const story = await storyQueries.getStoryByIdForUser(input.storyId, ctx.user.id); diff --git a/apps/frontend/src/components/auth-form.tsx b/apps/frontend/src/components/auth-form.tsx index 4405139a4..d12a1b4ff 100644 --- a/apps/frontend/src/components/auth-form.tsx +++ b/apps/frontend/src/components/auth-form.tsx @@ -68,7 +68,7 @@ export function AuthForm({ Continue with GitHub )} - {isMicrosoftSetup && } + {isMicrosoftSetup && }
diff --git a/apps/frontend/src/components/auth-microsoft-button.tsx b/apps/frontend/src/components/auth-microsoft-button.tsx index b13cec523..06a1d5650 100644 --- a/apps/frontend/src/components/auth-microsoft-button.tsx +++ b/apps/frontend/src/components/auth-microsoft-button.tsx @@ -12,9 +12,18 @@ export function useIsMicrosoftSetup(): boolean { return Boolean(isMicrosoftSetup.data); } -export function MicrosoftSignInButton() { +interface MicrosoftSignInButtonProps { + callbackUrl?: string; +} + +export function MicrosoftSignInButton({ callbackUrl }: MicrosoftSignInButtonProps = {}) { return ( - diff --git a/apps/frontend/src/lib/microsoft-auth.ts b/apps/frontend/src/lib/microsoft-auth.ts index fd4973315..1e7f01b5f 100644 --- a/apps/frontend/src/lib/microsoft-auth.ts +++ b/apps/frontend/src/lib/microsoft-auth.ts @@ -2,10 +2,10 @@ import { authClient } from './auth-client'; -export async function handleMicrosoftSignIn(): Promise { +export async function handleMicrosoftSignIn(callbackURL = '/'): Promise { await authClient.signIn.social({ provider: 'microsoft', - callbackURL: '/', + callbackURL, errorCallbackURL: '/login', }); } From 7002e87356e1fd919a9fffc3f7f65082f7f6654f Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Mon, 4 May 2026 12:38:49 +0200 Subject: [PATCH 5/7] fix(mcp): expose closeProjectSessions and add projectId to McpSession Required by routes.ts and mcp-endpoint.routes.ts after the real-time MCP settings update; unblocks tsc/lint and the Docker frontend build. Co-authored-by: Cursor --- apps/backend/src/mcp/server.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/mcp/server.ts b/apps/backend/src/mcp/server.ts index 7aef7813f..9309058ba 100644 --- a/apps/backend/src/mcp/server.ts +++ b/apps/backend/src/mcp/server.ts @@ -12,6 +12,7 @@ export interface McpSession { transport: StreamableHTTPServerTransport; server: McpServer; userId: string; + projectId: string; lastAccess: number; } @@ -49,10 +50,32 @@ export function createMcpServer(userId: string, projectId: string, settings: Mcp const server = new McpServer({ name: 'nao', version: '0.1.0' }, { capabilities: { tools: {} } }); const ctx = { userId, projectId, settings }; - registerAgentTools(server, ctx); - registerDataTools(server, ctx); - registerFileTools(server, ctx); - registerStoryTools(server, ctx); + if (settings.agentModeEnabled) { + registerAgentTools(server, ctx); + } + if (settings.toolsModeEnabled) { + registerDataTools(server, ctx); + registerFileTools(server, ctx); + } + if (settings.objectsModeEnabled) { + registerStoryTools(server, ctx); + } return server; } + +export async function closeProjectSessions(projectId: string): Promise { + const targets: McpSession[] = []; + for (const [id, session] of sessions) { + if (session.projectId === projectId) { + targets.push(session); + sessions.delete(id); + } + } + await Promise.all( + targets.map(async (session) => { + await session.transport.close().catch(() => {}); + await session.server.close().catch(() => {}); + }), + ); +} From 24f2c8dc2ee186f0edb1a1307958253b66418f4f Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Mon, 4 May 2026 17:34:25 +0200 Subject: [PATCH 6/7] update some issues and improve the tools --- .../migrations-postgres/0038_mcp_endpoint.sql | 3 +- .../meta/0038_snapshot.json | 9 +- .../migrations-postgres/meta/_journal.json | 2 +- .../migrations-sqlite/0038_mcp_endpoint.sql | 3 +- .../migrations-sqlite/meta/0038_snapshot.json | 9 +- .../migrations-sqlite/meta/_journal.json | 2 +- apps/backend/src/app.ts | 5 +- apps/backend/src/db/pg-schema.ts | 6 +- apps/backend/src/db/sqlite-schema.ts | 6 +- apps/backend/src/mcp/routes.ts | 13 +- apps/backend/src/mcp/tools/agent.ts | 23 +- apps/backend/src/mcp/tools/stories.ts | 203 +++++++++++++----- apps/backend/src/mcp/urls.ts | 22 ++ apps/backend/src/queries/chat.queries.ts | 2 +- apps/backend/src/queries/story.queries.ts | 47 ++++ apps/backend/src/trpc/chat-fork.routes.ts | 56 ++--- apps/backend/src/types/chat.ts | 2 +- apps/backend/src/utils/chat-message-story.ts | 64 ++++++ .../components/chat-messages/user-message.tsx | 2 + .../icons/model-context-protocol.svg | 1 + ...bar-layout.stories.standalone.$storyId.tsx | 29 ++- 21 files changed, 378 insertions(+), 131 deletions(-) create mode 100644 apps/backend/src/mcp/urls.ts create mode 100644 apps/backend/src/utils/chat-message-story.ts create mode 100644 apps/frontend/src/components/icons/model-context-protocol.svg diff --git a/apps/backend/migrations-postgres/0038_mcp_endpoint.sql b/apps/backend/migrations-postgres/0038_mcp_endpoint.sql index f50d570c5..889c1a64f 100644 --- a/apps/backend/migrations-postgres/0038_mcp_endpoint.sql +++ b/apps/backend/migrations-postgres/0038_mcp_endpoint.sql @@ -75,4 +75,5 @@ ALTER TABLE "story" ADD CONSTRAINT "story_project_id_project_id_fk" FOREIGN KEY ALTER TABLE "story" ADD CONSTRAINT "story_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "story_standalone_slug_unique" ON "story" USING btree ("project_id","user_id","slug") WHERE "story"."chat_id" IS NULL;--> statement-breakpoint CREATE INDEX "story_projectId_idx" ON "story" USING btree ("project_id");--> statement-breakpoint -CREATE INDEX "story_userId_idx" ON "story" USING btree ("user_id"); \ No newline at end of file +CREATE INDEX "story_userId_idx" ON "story" USING btree ("user_id");--> statement-breakpoint +ALTER TABLE "story" ADD CONSTRAINT "story_owner_required" CHECK ("story"."chat_id" IS NOT NULL OR ("story"."project_id" IS NOT NULL AND "story"."user_id" IS NOT NULL)); \ No newline at end of file diff --git a/apps/backend/migrations-postgres/meta/0038_snapshot.json b/apps/backend/migrations-postgres/meta/0038_snapshot.json index d11b82fc6..e74ba25bc 100644 --- a/apps/backend/migrations-postgres/meta/0038_snapshot.json +++ b/apps/backend/migrations-postgres/meta/0038_snapshot.json @@ -1,5 +1,5 @@ { - "id": "5a215008-f5a2-4a6c-b052-e72b9cefa90e", + "id": "4f8529cd-45d4-48a7-99d4-c384a6d46823", "prevId": "82faa6ea-95ce-444e-97f6-5649a163bd4d", "version": "7", "dialect": "postgresql", @@ -3403,7 +3403,12 @@ } }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "story_owner_required": { + "name": "story_owner_required", + "value": "\"story\".\"chat_id\" IS NOT NULL OR (\"story\".\"project_id\" IS NOT NULL AND \"story\".\"user_id\" IS NOT NULL)" + } + }, "isRLSEnabled": false }, "public.story_data_cache": { diff --git a/apps/backend/migrations-postgres/meta/_journal.json b/apps/backend/migrations-postgres/meta/_journal.json index 8a4d19974..9cb332242 100644 --- a/apps/backend/migrations-postgres/meta/_journal.json +++ b/apps/backend/migrations-postgres/meta/_journal.json @@ -271,7 +271,7 @@ { "idx": 38, "version": "7", - "when": 1777888607991, + "when": 1777899384814, "tag": "0038_mcp_endpoint", "breakpoints": true } diff --git a/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql b/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql index 5a6fc979e..b551fa911 100644 --- a/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql +++ b/apps/backend/migrations-sqlite/0038_mcp_endpoint.sql @@ -84,7 +84,8 @@ CREATE TABLE `__new_story` ( `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, FOREIGN KEY (`chat_id`) REFERENCES `chat`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "story_owner_required" CHECK("__new_story"."chat_id" IS NOT NULL OR ("__new_story"."project_id" IS NOT NULL AND "__new_story"."user_id" IS NOT NULL)) ); --> statement-breakpoint INSERT INTO `__new_story`("id", "chat_id", "project_id", "user_id", "slug", "title", "is_live", "is_live_text_dynamic", "cache_schedule", "cache_schedule_description", "archived_at", "created_at", "updated_at") SELECT "id", "chat_id", "project_id", "user_id", "slug", "title", "is_live", "is_live_text_dynamic", "cache_schedule", "cache_schedule_description", "archived_at", "created_at", "updated_at" FROM `story`;--> statement-breakpoint diff --git a/apps/backend/migrations-sqlite/meta/0038_snapshot.json b/apps/backend/migrations-sqlite/meta/0038_snapshot.json index f474b5b22..a6c392ee3 100644 --- a/apps/backend/migrations-sqlite/meta/0038_snapshot.json +++ b/apps/backend/migrations-sqlite/meta/0038_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "1726b1b6-8ef5-4f15-b484-62c3eff4eb3e", + "id": "3cb67d61-114d-442b-adae-0723c57d9ca8", "prevId": "fd9181fb-f275-49cd-8546-6d7cb0e5c0be", "tables": { "account": { @@ -3204,7 +3204,12 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "checkConstraints": { + "story_owner_required": { + "name": "story_owner_required", + "value": "\"story\".\"chat_id\" IS NOT NULL OR (\"story\".\"project_id\" IS NOT NULL AND \"story\".\"user_id\" IS NOT NULL)" + } + } }, "story_data_cache": { "name": "story_data_cache", diff --git a/apps/backend/migrations-sqlite/meta/_journal.json b/apps/backend/migrations-sqlite/meta/_journal.json index 61349a454..3dd0c3db1 100644 --- a/apps/backend/migrations-sqlite/meta/_journal.json +++ b/apps/backend/migrations-sqlite/meta/_journal.json @@ -271,7 +271,7 @@ { "idx": 38, "version": "6", - "when": 1777888554345, + "when": 1777899380548, "tag": "0038_mcp_endpoint", "breakpoints": true } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index deee53e7d..381e93a59 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -57,6 +57,7 @@ const app = fastify({ : true, bodyLimit: 35 * 1024 * 1024, // ~25 MB audio * 4/3 base64 overhead + JSON envelope routerOptions: { maxParamLength: 2048 }, + trustProxy: true, }).withTypeProvider(); export type App = typeof app; @@ -185,7 +186,7 @@ async function proxyToBetterAuth(url: string, request: { headers: Record { - const url = new URL('/api/auth/.well-known/oauth-protected-resource', `http://${request.headers.host}`); + const url = new URL('/api/auth/.well-known/oauth-protected-resource', env.BETTER_AUTH_URL); const response = await proxyToBetterAuth(url.toString(), request); reply.status(response.status); response.headers.forEach((value, key) => reply.header(key, value)); @@ -193,7 +194,7 @@ app.get('/.well-known/oauth-protected-resource', async (request, reply) => { }); app.get('/.well-known/oauth-authorization-server', async (request, reply) => { - const url = new URL('/api/auth/.well-known/oauth-authorization-server', `http://${request.headers.host}`); + const url = new URL('/api/auth/.well-known/oauth-authorization-server', env.BETTER_AUTH_URL); const response = await proxyToBetterAuth(url.toString(), request); reply.status(response.status); response.headers.forEach((value, key) => reply.header(key, value)); diff --git a/apps/backend/src/db/pg-schema.ts b/apps/backend/src/db/pg-schema.ts index bdb9e4a92..96ab657bf 100644 --- a/apps/backend/src/db/pg-schema.ts +++ b/apps/backend/src/db/pg-schema.ts @@ -241,7 +241,7 @@ export const chatMessage = pgTable( llmProvider: text('llm_provider').$type(), llmModelId: text('llm_model_id'), supersededAt: timestamp('superseded_at'), - source: text('source', { enum: ['slack', 'teams', 'telegram', 'whatsapp', 'web'] }), + source: text('source', { enum: ['slack', 'teams', 'telegram', 'whatsapp', 'web', 'mcp'] }), isForked: boolean('isForked'), citation: jsonb('citation').$type(), createdAt: timestamp('created_at').defaultNow().notNull(), @@ -516,6 +516,10 @@ export const story = pgTable( uniqueIndex('story_standalone_slug_unique') .on(t.projectId, t.userId, t.slug) .where(sql`${t.chatId} IS NULL`), + check( + 'story_owner_required', + sql`${t.chatId} IS NOT NULL OR (${t.projectId} IS NOT NULL AND ${t.userId} IS NOT NULL)`, + ), index('story_chatId_idx').on(t.chatId), index('story_projectId_idx').on(t.projectId), index('story_userId_idx').on(t.userId), diff --git a/apps/backend/src/db/sqlite-schema.ts b/apps/backend/src/db/sqlite-schema.ts index a9a13621a..598e2697c 100644 --- a/apps/backend/src/db/sqlite-schema.ts +++ b/apps/backend/src/db/sqlite-schema.ts @@ -251,7 +251,7 @@ export const chatMessage = sqliteTable( llmProvider: text('llm_provider').$type(), llmModelId: text('llm_model_id'), supersededAt: integer('superseded_at', { mode: 'timestamp_ms' }), - source: text('source', { enum: ['slack', 'teams', 'telegram', 'whatsapp', 'web'] }), + source: text('source', { enum: ['slack', 'teams', 'telegram', 'whatsapp', 'web', 'mcp'] }), isForked: integer('isForked', { mode: 'boolean' }), citation: text('citation', { mode: 'json' }).$type(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) @@ -543,6 +543,10 @@ export const story = sqliteTable( uniqueIndex('story_standalone_slug_unique') .on(t.projectId, t.userId, t.slug) .where(sql`chat_id IS NULL`), + check( + 'story_owner_required', + sql`${t.chatId} IS NOT NULL OR (${t.projectId} IS NOT NULL AND ${t.userId} IS NOT NULL)`, + ), index('story_chatId_idx').on(t.chatId), index('story_projectId_idx').on(t.projectId), index('story_userId_idx').on(t.userId), diff --git a/apps/backend/src/mcp/routes.ts b/apps/backend/src/mcp/routes.ts index 94059a4db..8cd3a8205 100644 --- a/apps/backend/src/mcp/routes.ts +++ b/apps/backend/src/mcp/routes.ts @@ -1,13 +1,14 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyReply } from 'fastify'; import type { App } from '../app'; +import { env } from '../env'; import { getMcpEndpointSettings } from '../queries/mcp-endpoint.queries'; import { resolveUserId } from './auth'; import { createMcpServer, resolveProjectId, sessions } from './server'; -function replyUnauthorized(request: FastifyRequest, reply: FastifyReply) { - const origin = `${request.protocol}://${request.headers.host ?? request.hostname}`; +function replyUnauthorized(reply: FastifyReply) { + const origin = env.BETTER_AUTH_URL.replace(/\/+$/, ''); const wwwAuth = `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`; return reply .status(401) @@ -20,7 +21,7 @@ export const mcpServerRoutes = async (app: App) => { app.post('/', async (request, reply) => { const userId = await resolveUserId(request); if (!userId) { - return replyUnauthorized(request, reply); + return replyUnauthorized(reply); } const existingSessionId = request.headers['mcp-session-id'] as string | undefined; @@ -62,7 +63,7 @@ export const mcpServerRoutes = async (app: App) => { app.get('/', async (request, reply) => { const userId = await resolveUserId(request); if (!userId) { - return replyUnauthorized(request, reply); + return replyUnauthorized(reply); } const sessionId = request.headers['mcp-session-id'] as string | undefined; @@ -83,7 +84,7 @@ export const mcpServerRoutes = async (app: App) => { app.delete('/', async (request, reply) => { const userId = await resolveUserId(request); if (!userId) { - return replyUnauthorized(request, reply); + return replyUnauthorized(reply); } const sessionId = request.headers['mcp-session-id'] as string | undefined; diff --git a/apps/backend/src/mcp/tools/agent.ts b/apps/backend/src/mcp/tools/agent.ts index 107057e6e..d789b3990 100644 --- a/apps/backend/src/mcp/tools/agent.ts +++ b/apps/backend/src/mcp/tools/agent.ts @@ -10,12 +10,19 @@ import type { UIMessage } from '../../types/chat'; import { logger } from '../../utils/logger'; import type { McpContext, ToolExtra } from '../logging'; import { withLogging } from '../logging'; +import { chatUrl } from '../urls'; export function registerAgentTools(server: McpServer, ctx: McpContext): void { server.registerTool( 'ask_nao', { - description: 'Ask nao an analytics question in natural language. Creates a chat that is visible in the UI.', + description: + 'Ask nao an analytics question in natural language. Creates a chat that is visible in the UI.\n\n' + + 'Use this for ad-hoc data exploration and Q&A. ' + + 'To create a persistent Nao Story (markdown dashboard with embedded charts/tables), do NOT ask nao in natural language — use `execute_sql` → `build_chart` → `create_story` instead. ' + + 'To browse or update existing stories, use `list_stories` / `get_story` / `update_story`.\n\n' + + 'Returns `chatId` and a `url` that opens the chat in the Nao UI — surface the URL to the user as a clickable link so they can jump to the conversation (and any rendered charts/tables). ' + + 'The returned `chatId` can also be passed to `create_story` as `chat_id` to attach a follow-up story to this conversation.', inputSchema: { question: z.string().describe('The analytics question'), conversation_id: z @@ -23,7 +30,8 @@ export function registerAgentTools(server: McpServer, ctx: McpContext): void { .optional() .describe( 'Existing chat ID to continue a conversation. Omit to start a new chat. ' + - 'Reuse the ID returned by a previous ask_nao call from the same conversation.', + 'Reuse the ID returned by a previous ask_nao call ONLY when the new question clearly builds on the previous nao exchange (same data, same topic). ' + + 'If the topic shifts or the prior nao reply was a refusal / off-topic, omit this to start a fresh chat — otherwise the follow-up inherits the prior context and may repeat the refusal.', ), }, }, @@ -51,12 +59,13 @@ export function registerAgentTools(server: McpServer, ctx: McpContext): void { tokenUsage: result.usage, }); + const url = chatUrl(chat.id); return { content: [ { type: 'text' as const, text: result.text }, - { type: 'text' as const, text: `\n\n[conversation_id: ${chat.id}]` }, + { type: 'text' as const, text: `\n\n[conversation_id: ${chat.id}]\n[chat_url: ${url}]` }, ], - toolOutput: { chatId: chat.id, text: result.text }, + toolOutput: { chatId: chat.id, text: result.text, url }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -83,6 +92,7 @@ async function buildChatContext( id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: question }], + source: 'mcp', }; if (conversationId) { @@ -98,7 +108,10 @@ async function buildChatContext( } const chatId = crypto.randomUUID(); - await chatQueries.createChat({ id: chatId, projectId, userId, title: question.slice(0, 80) }, { text: question }); + await chatQueries.createChat( + { id: chatId, projectId, userId, title: question.slice(0, 80) }, + { text: question, source: 'mcp' }, + ); return { chat: { id: chatId, projectId, userId }, uiMessages: [userMessage], diff --git a/apps/backend/src/mcp/tools/stories.ts b/apps/backend/src/mcp/tools/stories.ts index 9217778ae..9eed448e4 100644 --- a/apps/backend/src/mcp/tools/stories.ts +++ b/apps/backend/src/mcp/tools/stories.ts @@ -1,18 +1,22 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; +import * as chatQueries from '../../queries/chat.queries'; import type { UserStoryRow } from '../../queries/story.queries'; import * as storyQueries from '../../queries/story.queries'; +import { pinQueryDataToChat, pinStoryMessageToChat } from '../../utils/chat-message-story'; import { logger } from '../../utils/logger'; import type { McpContext } from '../logging'; import { withLogging } from '../logging'; +import { storyChatUrl, storyUrl } from '../urls'; export function registerStoryTools(server: McpServer, ctx: McpContext): void { server.registerTool( 'list_stories', { title: 'List Stories', - description: 'List analytics stories (dashboards/reports) in the current project.', + description: + 'List analytics stories (dashboards/reports) in the current project. Each story includes a `url` that opens the rendered story in the Nao UI, and a `chatUrl` that opens the underlying chat conversation (null for standalone stories).', inputSchema: { limit: z.number().optional().default(20).describe('Max stories to return (default 20, max 100)'), archived: z.boolean().optional().default(false).describe('Include archived stories'), @@ -24,12 +28,14 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { archived, limit, }); - const result = stories.map(({ id, title, createdAt, updatedAt, archivedAt }) => ({ - id, - title, - createdAt, - updatedAt, - archived: archivedAt !== null, + const result = stories.map((story) => ({ + id: story.id, + title: story.title, + createdAt: story.createdAt, + updatedAt: story.updatedAt, + archived: story.archivedAt !== null, + url: storyUrl(story), + chatUrl: storyChatUrl(story), })); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], toolOutput: result }; } catch (error) { @@ -44,7 +50,8 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { 'get_story', { title: 'Get Story', - description: 'Retrieve a full story including its latest content/code.', + description: + 'Retrieve a full story including its latest content/code. Returns a `url` that opens the rendered story in the Nao UI, and a `chatUrl` that opens the underlying chat conversation (null for standalone stories).', inputSchema: { story_id: z.string().describe('The story ID to retrieve'), }, @@ -66,6 +73,8 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { archived: story.archivedAt !== null, createdAt: story.createdAt, updatedAt: story.updatedAt, + url: storyUrl(story), + chatUrl: storyChatUrl(story), }; return { content: [{ type: 'text' as const, text: JSON.stringify(output) }], @@ -87,7 +96,7 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { { title: 'Create Story', description: - 'Create a new analytics story. Stories are markdown documents with embedded chart/table components rendered by the Nao UI.\n\nWorkflow for stories with charts:\n1. execute_sql → get rows + query_id\n2. build_chart → get a `` block string\n3. create_story → embed the block in `content`; pass SQL rows in `query_data`\n\nSupported blocks:\n- Charts: use `build_chart` to generate the correct `` block — do NOT write these manually.\n- Tables: `
`\n- Grids: `...blocks...` (1–4 columns)\n\nOmit `content` to create an empty story.', + 'Create a new analytics story. Stories are markdown documents with embedded chart/table components rendered by the Nao UI.\n\nWorkflow for stories with charts:\n1. execute_sql → get rows + query_id\n2. build_chart → get a `` block string\n3. create_story → embed the block in `content`; pass SQL rows in `query_data`\n\nSupported blocks:\n- Charts: use `build_chart` to generate the correct `` block — do NOT write these manually.\n- Tables: `
`\n- Grids: `...blocks...` (1–4 columns)\n\nOmit `content` to create an empty story.\n\nPass `chat_id` to attach the story to an existing chat (e.g. one returned by `ask_nao`). Omit it to create a standalone story listed at the project level.\n\nReturns a `url` that opens the rendered story in the Nao UI and a `chatUrl` that opens the underlying chat (null for standalone stories) — surface the relevant link to the user as a clickable link in your reply.', inputSchema: { title: z.string().describe('Story title'), content: z @@ -105,31 +114,43 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { .describe( 'Query results keyed by query_id (query_id → { columns, data }). Required for stories with or
blocks so the Nao UI can render data.', ), + chat_id: z + .string() + .optional() + .describe( + 'Attach the story to an existing chat (e.g. the chat ID returned by `ask_nao`). ' + + 'Omit to create a standalone story listed at the project level. ' + + 'When provided, the chat must belong to the current user.', + ), }, }, - withLogging('create_story', ctx, async ({ title, content, query_data }) => { + withLogging('create_story', ctx, async ({ title, content, query_data, chat_id }) => { try { - const slug = await getUniqueStandaloneSlug(ctx, title); + const slug = generateSlug(title); const code = content ?? `# ${title}\n`; + const story = chat_id + ? await createChatLinkedStory({ chatId: chat_id, slug, title, code, ctx }) + : await createStandaloneStory({ slug, title, code, ctx }); - const version = await storyQueries.createStandaloneVersion({ - userId: ctx.userId, - projectId: ctx.projectId, - slug, - title, - code, - action: 'create', - source: 'user', - }); + if ('error' in story) { + return { content: [{ type: 'text' as const, text: `Error: ${story.error}` }], isError: true }; + } - const story = await storyQueries.getStandaloneStoryByUserAndSlug(ctx.userId, ctx.projectId, slug); - if (query_data && story) { - await storyQueries.upsertStoryDataCacheByStoryId( - story.id, - query_data as Record, - ); + if (query_data) { + const typedQueryData = query_data as Record; + await storyQueries.upsertStoryDataCacheByStoryId(story.id, typedQueryData); + if (chat_id) { + await pinQueryDataToChat(chat_id, typedQueryData); + } } - const output = { id: story!.id, title: version.title, createdAt: story!.createdAt }; + const storyForUrl = { id: story.id, slug: story.slug, chatId: story.chatId }; + const output = { + id: story.id, + title: story.title, + createdAt: story.createdAt, + url: storyUrl(storyForUrl), + chatUrl: storyChatUrl(storyForUrl), + }; return { content: [{ type: 'text' as const, text: JSON.stringify(output) }], toolOutput: output, @@ -150,7 +171,7 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { { title: 'Update Story', description: - 'Update a story title and/or content. Omit fields to keep their current values.\n\nWhen adding or replacing charts, use `build_chart` first to generate the correct `` block, then pass it in `content`. Include the SQL rows for any new query_ids in `query_data`.', + 'Update a story title and/or content. Omit fields to keep their current values.\n\nWhen adding or replacing charts, use `build_chart` first to generate the correct `` block, then pass it in `content`. Include the SQL rows for any new query_ids in `query_data`.\n\nReturns a `url` that opens the rendered story in the Nao UI and a `chatUrl` that opens the underlying chat (null for standalone stories) — surface the relevant link to the user as a clickable link in your reply.', inputSchema: { story_id: z.string().describe('The story ID to update'), title: z.string().optional().describe('New title (omit to keep current)'), @@ -173,24 +194,30 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { const newTitle = title ?? story.title; const newCode = content ?? latestVersion?.code ?? `# ${newTitle}\n`; const updated = await saveNewVersion(story, ctx, newTitle, newCode); - if (query_data && !story.chatId) { - const storyForCache = - (await storyQueries.getStandaloneStoryByUserAndSlug(ctx.userId, ctx.projectId, story.slug)) ?? - story; - const existingCache = await storyQueries.getStoryDataCacheByStoryId(storyForCache.id); - const mergedQueryData = { - ...((existingCache?.queryData as Record< - string, - { data: unknown[]; columns: string[] } - > | null) ?? {}), - ...query_data, - }; - await storyQueries.upsertStoryDataCacheByStoryId( - storyForCache.id, - mergedQueryData as Record, - ); + const output = { ...updated, url: storyUrl(story), chatUrl: storyChatUrl(story) }; + if (query_data) { + const typedQueryData = query_data as Record; + if (story.chatId) { + await pinQueryDataToChat(story.chatId, typedQueryData); + } else { + const storyForCache = + (await storyQueries.getStandaloneStoryByUserAndSlug( + ctx.userId, + ctx.projectId, + story.slug, + )) ?? story; + const existingCache = await storyQueries.getStoryDataCacheByStoryId(storyForCache.id); + const mergedQueryData = { + ...((existingCache?.queryData as Record< + string, + { data: unknown[]; columns: string[] } + > | null) ?? {}), + ...typedQueryData, + }; + await storyQueries.upsertStoryDataCacheByStoryId(storyForCache.id, mergedQueryData); + } } - return { content: [{ type: 'text' as const, text: JSON.stringify(updated) }], toolOutput: updated }; + return { content: [{ type: 'text' as const, text: JSON.stringify(output) }], toolOutput: output }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error(`MCP update_story error: ${message}`, { @@ -260,17 +287,6 @@ export function registerStoryTools(server: McpServer, ctx: McpContext): void { ); } -async function getUniqueStandaloneSlug(ctx: McpContext, title: string): Promise { - const baseSlug = generateSlug(title); - let candidate = baseSlug; - let suffix = 2; - while (await storyQueries.getStandaloneStoryByUserAndSlug(ctx.userId, ctx.projectId, candidate)) { - candidate = `${baseSlug}-${suffix}`; - suffix += 1; - } - return candidate; -} - function generateSlug(title: string): string { return ( title @@ -280,6 +296,81 @@ function generateSlug(title: string): string { ); } +type CreatedStory = { id: string; title: string; slug: string; chatId: string | null; createdAt: Date }; +type CreateStoryResult = CreatedStory | { error: string }; + +async function createStandaloneStory(args: { + slug: string; + title: string; + code: string; + ctx: McpContext; +}): Promise { + const story = await storyQueries.createStandaloneStory({ + userId: args.ctx.userId, + projectId: args.ctx.projectId, + slug: args.slug, + title: args.title, + code: args.code, + source: 'user', + }); + + if (!story) { + return { + error: `A story with title "${args.title}" already exists. Pick a different title or use update_story to modify the existing one.`, + }; + } + return { ...story, chatId: null }; +} + +async function createChatLinkedStory(args: { + chatId: string; + slug: string; + title: string; + code: string; + ctx: McpContext; +}): Promise { + const ownerId = await chatQueries.getChatOwnerId(args.chatId); + if (ownerId !== args.ctx.userId) { + return { error: `Chat not found: ${args.chatId}` }; + } + + const existing = await storyQueries.getStoryByChatAndSlug(args.chatId, args.slug); + if (existing) { + return { + error: `A story with title "${args.title}" already exists in this chat. Pick a different title or use update_story to modify the existing one.`, + }; + } + + const version = await storyQueries.createStoryVersion({ + chatId: args.chatId, + slug: args.slug, + title: args.title, + code: args.code, + action: 'create', + source: 'assistant', + }); + const created = await storyQueries.getStoryByChatAndSlug(args.chatId, args.slug); + if (!created) { + throw new Error(`Failed to retrieve created story: ${args.chatId}/${args.slug}`); + } + + await pinStoryMessageToChat({ + chatId: args.chatId, + slug: args.slug, + title: args.title, + code: args.code, + version: version.version, + }); + + return { + id: created.id, + title: created.title, + slug: created.slug, + chatId: created.chatId, + createdAt: created.createdAt, + }; +} + async function resolveStory(storyId: string, ctx: McpContext): Promise { const story = await storyQueries.getStoryByIdForUser(storyId, ctx.userId); if (!story) { diff --git a/apps/backend/src/mcp/urls.ts b/apps/backend/src/mcp/urls.ts new file mode 100644 index 000000000..55ab07d5b --- /dev/null +++ b/apps/backend/src/mcp/urls.ts @@ -0,0 +1,22 @@ +import { env } from '../env'; + +export function chatUrl(chatId: string): string { + return appUrl(`/${chatId}`); +} + +export function storyUrl(story: { id: string; chatId: string | null; slug: string }): string { + if (story.chatId) { + return appUrl(`/stories/preview/${story.chatId}/${story.slug}`); + } + return appUrl(`/stories/standalone/${story.id}`); +} + +export function storyChatUrl(story: { chatId: string | null }): string | null { + return story.chatId ? chatUrl(story.chatId) : null; +} + +function appUrl(path: string): string { + const base = env.BETTER_AUTH_URL.replace(/\/+$/, ''); + const suffix = path.startsWith('/') ? path : `/${path}`; + return `${base}${suffix}`; +} diff --git a/apps/backend/src/queries/chat.queries.ts b/apps/backend/src/queries/chat.queries.ts index 41868eb72..92d844e81 100644 --- a/apps/backend/src/queries/chat.queries.ts +++ b/apps/backend/src/queries/chat.queries.ts @@ -262,7 +262,7 @@ export const createChat = async ( newChat: NewChat, newUserMessage: { text: string; - source?: 'slack' | 'teams' | 'telegram' | 'whatsapp' | 'web'; + source?: UIMessage['source']; citation?: CitationData; }, additionalParts: UIMessagePart[] = [], diff --git a/apps/backend/src/queries/story.queries.ts b/apps/backend/src/queries/story.queries.ts index 5651dff82..d4514899d 100644 --- a/apps/backend/src/queries/story.queries.ts +++ b/apps/backend/src/queries/story.queries.ts @@ -234,6 +234,53 @@ export async function createStandaloneVersion(data: { return { ...created, title: data.title }; } +export async function createStandaloneStory(data: { + userId: string; + projectId: string; + slug: string; + title: string; + code: string; + source: 'assistant' | 'user'; +}): Promise<{ id: string; slug: string; title: string; createdAt: Date; version: number } | null> { + return db.transaction(async (tx) => { + const [story] = await tx + .insert(s.story) + .values({ + projectId: data.projectId, + userId: data.userId, + slug: data.slug, + title: data.title, + }) + .onConflictDoNothing() + .returning() + .execute(); + + if (!story) { + return null; + } + + const [version] = await tx + .insert(s.storyVersion) + .values({ + storyId: story.id, + code: data.code, + action: 'create', + source: data.source, + version: 1, + }) + .returning() + .execute(); + + return { + id: story.id, + slug: story.slug, + title: story.title, + createdAt: story.createdAt, + version: version.version, + }; + }); +} + export async function deleteStory(storyId: string): Promise { await db.delete(s.story).where(eq(s.story.id, storyId)).execute(); } diff --git a/apps/backend/src/trpc/chat-fork.routes.ts b/apps/backend/src/trpc/chat-fork.routes.ts index 85951eca5..e6a71f64b 100644 --- a/apps/backend/src/trpc/chat-fork.routes.ts +++ b/apps/backend/src/trpc/chat-fork.routes.ts @@ -7,7 +7,8 @@ import * as sharedChatQueries from '../queries/shared-chat.queries'; import * as sharedStoryQueries from '../queries/shared-story.queries'; import * as storyQueries from '../queries/story.queries'; import { compactionService } from '../services/compaction'; -import type { ForkMetadata, UIMessage, UIMessagePart } from '../types/chat'; +import type { ForkMetadata, UIMessage } from '../types/chat'; +import { buildQueryDataParts, pinStoryMessageToChat } from '../utils/chat-message-story'; import { canSendProcedure, projectProtectedProcedure, protectedProcedure } from './trpc'; const shareTypeSchema = z.enum(['chat', 'story']); @@ -58,7 +59,13 @@ export const chatForkRoutes = { const latestVersion = await storyQueries.getLatestVersionByStoryId(story.id); await storyQueries.assignChatToStory(story.id, chat.id); - await pinStoryMessageToChat(chat.id, story.slug, story.title, story.code, latestVersion?.version ?? 1); + await pinStoryMessageToChat({ + chatId: chat.id, + slug: story.slug, + title: story.title, + code: story.code, + version: latestVersion?.version ?? 1, + }); return { chatId: chat.id }; }), @@ -202,24 +209,10 @@ function buildSelectionContextMessage(sourceTitle: string, selection: SelectionI function buildQueryDataMessages( queryData: Record | null, ): Array> { - if (!queryData || Object.keys(queryData).length === 0) { + const parts = buildQueryDataParts(queryData); + if (parts.length === 0) { return []; } - - const parts: UIMessagePart[] = Object.entries(queryData).map( - ([queryId, { data, columns }]) => - ({ - type: 'tool-execute_sql', - toolName: 'execute_sql', - toolCallId: crypto.randomUUID(), - state: 'output-available', - input: { sql_query: '' }, - output: { id: queryId as `query_${string}`, data, columns, row_count: data.length }, - providerExecuted: false, - errorText: undefined, - }) as unknown as UIMessagePart, - ); - return [{ role: 'assistant', isForked: true, parts }]; } @@ -233,32 +226,7 @@ async function createStoryInFork(chatId: string, slug: string, title: string, co source: 'assistant', }); - await pinStoryMessageToChat(chatId, slug, title, code, version.version); -} - -async function pinStoryMessageToChat( - chatId: string, - slug: string, - title: string, - code: string, - versionNumber: number, -): Promise { - await chatQueries.upsertMessage({ - chatId, - role: 'assistant', - parts: [ - { - type: 'tool-story', - toolCallId: crypto.randomUUID(), - toolName: 'story', - state: 'output-available', - input: { action: 'create', id: slug, title, code }, - output: { _version: '1', success: true, id: slug, version: versionNumber, code, title }, - errorText: undefined, - providerExecuted: false, - } as UIMessagePart, - ], - }); + await pinStoryMessageToChat({ chatId, slug, title, code, version: version.version }); } async function copyStoriesToFork(sourceChatId: string, forkChatId: string): Promise { diff --git a/apps/backend/src/types/chat.ts b/apps/backend/src/types/chat.ts index aa350c5d5..7ca0cf7c6 100644 --- a/apps/backend/src/types/chat.ts +++ b/apps/backend/src/types/chat.ts @@ -45,7 +45,7 @@ export interface ChatListItem { export type UIMessage = UIGenericMessage & { feedback?: MessageFeedback; - source?: 'slack' | 'teams' | 'telegram' | 'whatsapp' | 'web'; + source?: 'slack' | 'teams' | 'telegram' | 'whatsapp' | 'web' | 'mcp'; isForked?: boolean; citation?: CitationData; }; diff --git a/apps/backend/src/utils/chat-message-story.ts b/apps/backend/src/utils/chat-message-story.ts new file mode 100644 index 000000000..d92c3c1c9 --- /dev/null +++ b/apps/backend/src/utils/chat-message-story.ts @@ -0,0 +1,64 @@ +import * as chatQueries from '../queries/chat.queries'; +import type { UIMessagePart } from '../types/chat'; + +export async function pinStoryMessageToChat(args: { + chatId: string; + slug: string; + title: string; + code: string; + version: number; +}): Promise { + const { chatId, slug, title, code, version } = args; + + await chatQueries.upsertMessage({ + chatId, + role: 'assistant', + parts: [ + { + type: 'tool-story', + toolCallId: crypto.randomUUID(), + toolName: 'story', + state: 'output-available', + input: { action: 'create', id: slug, title, code }, + output: { _version: '1', success: true, id: slug, version, code, title }, + errorText: undefined, + providerExecuted: false, + } as UIMessagePart, + ], + }); +} + +export type StoryQueryDataMap = Record; + +export async function pinQueryDataToChat(chatId: string, queryData: StoryQueryDataMap): Promise { + const parts = buildQueryDataParts(queryData); + if (parts.length === 0) { + return; + } + + await chatQueries.upsertMessage({ + chatId, + role: 'assistant', + isForked: true, + parts, + }); +} + +export function buildQueryDataParts(queryData: StoryQueryDataMap | null | undefined): UIMessagePart[] { + if (!queryData) { + return []; + } + return Object.entries(queryData).map( + ([queryId, { data, columns }]) => + ({ + type: 'tool-execute_sql', + toolName: 'execute_sql', + toolCallId: crypto.randomUUID(), + state: 'output-available', + input: { sql_query: '' }, + output: { id: queryId as `query_${string}`, data, columns, row_count: data.length }, + providerExecuted: false, + errorText: undefined, + }) as unknown as UIMessagePart, + ); +} diff --git a/apps/frontend/src/components/chat-messages/user-message.tsx b/apps/frontend/src/components/chat-messages/user-message.tsx index 9ad49da6c..cef430353 100644 --- a/apps/frontend/src/components/chat-messages/user-message.tsx +++ b/apps/frontend/src/components/chat-messages/user-message.tsx @@ -22,6 +22,7 @@ import { STORY_MENTION_ID } from '@/components/chat-input-prompt'; import StoryIcon from '@/components/ui/story-icon'; import SlackIcon from '@/components/icons/slack.svg'; import TeamsIcon from '@/components/icons/microsoft-teams.svg'; +import McpIcon from '@/components/icons/model-context-protocol.svg'; import TelegramIcon from '@/components/icons/telegram.svg'; import WhatsAppIcon from '@/components/icons/whatsapp.svg'; @@ -50,6 +51,7 @@ const MESSAGE_SOURCES = { teams: { icon: , label: 'sent in Teams' }, telegram: { icon: , label: 'sent in Telegram' }, whatsapp: { icon: , label: 'sent in WhatsApp' }, + mcp: { icon: , label: 'sent via MCP' }, } as const; function MessageSourceBadge({ source }: { source: UIMessage['source'] }) { diff --git a/apps/frontend/src/components/icons/model-context-protocol.svg b/apps/frontend/src/components/icons/model-context-protocol.svg new file mode 100644 index 000000000..7aae9e1a3 --- /dev/null +++ b/apps/frontend/src/components/icons/model-context-protocol.svg @@ -0,0 +1 @@ +Model Context Protocol \ No newline at end of file diff --git a/apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx b/apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx index 03c6ae245..9d4b7a837 100644 --- a/apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.stories.standalone.$storyId.tsx @@ -1,5 +1,5 @@ import { splitCodeIntoSegments } from '@nao/shared/story-segments'; -import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { MessageSquare, Loader2 } from 'lucide-react'; import { useCallback, useMemo } from 'react'; @@ -12,6 +12,7 @@ import { StoryDownload } from '@/components/story-download'; import { StoryChartEmbed, StoryTableEmbed } from '@/components/story-embeds'; import { SegmentList } from '@/components/story-rendering'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { SelectionProvider } from '@/contexts/text-selection'; import { chatPendingCitationStore } from '@/stores/chat-pending-citation'; import { trpc } from '@/main'; @@ -25,36 +26,52 @@ function StandaloneStoryPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); - const { data: story } = useSuspenseQuery(trpc.story.getStandalone.queryOptions({ storyId })); + const storyQuery = useQuery(trpc.story.getStandalone.queryOptions({ storyId })); + const story = storyQuery.data; const openStandaloneMutation = useMutation( trpc.chatFork.openStandalone.mutationOptions({ onSuccess: ({ chatId }) => { queryClient.invalidateQueries({ queryKey: trpc.story.listAll.queryKey() }); queryClient.invalidateQueries({ queryKey: trpc.story.listStandalone.queryKey() }); - navigate({ to: '/$chatId', params: { chatId }, state: { openStorySlug: story.slug } }); + navigate({ to: '/$chatId', params: { chatId }, state: { openStorySlug: story?.slug } }); }, }), ); const handleSelectionAsk = useCallback( (data: SelectionData) => { - if (!story.chatId) { + if (!story?.chatId) { return; } chatPendingCitationStore.set({ chatId: story.chatId, storySlug: story.slug, ...data }); navigate({ to: '/$chatId', params: { chatId: story.chatId } }); }, - [navigate, story.chatId, story.slug], + [navigate, story?.chatId, story?.slug], ); const handleOpenChat = useCallback(() => { + if (!story) { + return; + } if (story.chatId) { navigate({ to: '/$chatId', params: { chatId: story.chatId }, state: { openStorySlug: story.slug } }); } else { openStandaloneMutation.mutate({ storyId }); } - }, [story.chatId, story.slug, storyId, navigate, openStandaloneMutation]); + }, [story, storyId, navigate, openStandaloneMutation]); + + if (storyQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (!story) { + return
Not Found
; + } return (
From e2d0e4442d04b0f757e0bf173b8f4616ef05fd6c Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Tue, 5 May 2026 10:47:39 +0200 Subject: [PATCH 7/7] improve description of tools --- apps/frontend/src/components/settings/mcp-endpoint.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/settings/mcp-endpoint.tsx b/apps/frontend/src/components/settings/mcp-endpoint.tsx index 90a3cf6e8..4c0b59fbc 100644 --- a/apps/frontend/src/components/settings/mcp-endpoint.tsx +++ b/apps/frontend/src/components/settings/mcp-endpoint.tsx @@ -85,7 +85,7 @@ export function McpEndpointSettings({ isAdmin }: Props) { toggle('toolsModeEnabled', v)} disabled={!isAdmin || !enabled || pending}