add mcp
This commit is contained in:
@@ -11,8 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@better-auth/oauth-provider": "^1.4.19",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/instrument-serif": "^5.2.8",
|
||||
"@modelcontextprotocol/sdk": "^1.27.0",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@tanstack/react-form": "^1.23.5",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { anonymousClient } from "better-auth/client/plugins";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [anonymousClient()],
|
||||
plugins: [anonymousClient(), oauthProviderClient()],
|
||||
});
|
||||
|
||||
7
apps/web/src/lib/server-client.ts
Normal file
7
apps/web/src/lib/server-client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { auth } from "@zendegi/auth";
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
||||
|
||||
export const serverClient = createAuthClient({
|
||||
plugins: [oauthProviderResourceClient(auth)],
|
||||
});
|
||||
@@ -14,9 +14,15 @@ import { Route as TimelineRouteImport } from './routes/timeline'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as DemoRouteImport } from './routes/demo'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as ConsentRouteImport } from './routes/consent'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as TimelineTimelineIdRouteImport } from './routes/timeline.$timelineId'
|
||||
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.well-known]/openid-configuration'
|
||||
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.well-known]/oauth-protected-resource'
|
||||
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.well-known]/oauth-authorization-server'
|
||||
import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.well-known]/oauth-authorization-server.api.auth'
|
||||
|
||||
const TimelinesRoute = TimelinesRouteImport.update({
|
||||
id: '/timelines',
|
||||
@@ -43,6 +49,11 @@ const DashboardRoute = DashboardRouteImport.update({
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ConsentRoute = ConsentRouteImport.update({
|
||||
id: '/consent',
|
||||
path: '/consent',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -53,84 +64,154 @@ const TimelineTimelineIdRoute = TimelineTimelineIdRouteImport.update({
|
||||
path: '/$timelineId',
|
||||
getParentRoute: () => TimelineRoute,
|
||||
} as any)
|
||||
const DotwellKnownOpenidConfigurationRoute =
|
||||
DotwellKnownOpenidConfigurationRouteImport.update({
|
||||
id: '/.well-known/openid-configuration',
|
||||
path: '/.well-known/openid-configuration',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DotwellKnownOauthProtectedResourceRoute =
|
||||
DotwellKnownOauthProtectedResourceRouteImport.update({
|
||||
id: '/.well-known/oauth-protected-resource',
|
||||
path: '/.well-known/oauth-protected-resource',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DotwellKnownOauthAuthorizationServerRoute =
|
||||
DotwellKnownOauthAuthorizationServerRouteImport.update({
|
||||
id: '/.well-known/oauth-authorization-server',
|
||||
path: '/.well-known/oauth-authorization-server',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiMcpSplatRoute = ApiMcpSplatRouteImport.update({
|
||||
id: '/api/mcp/$',
|
||||
path: '/api/mcp/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DotwellKnownOauthAuthorizationServerApiAuthRoute =
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({
|
||||
id: '/api/auth',
|
||||
path: '/api/auth',
|
||||
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/consent': typeof ConsentRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/timeline': typeof TimelineRouteWithChildren
|
||||
'/timelines': typeof TimelinesRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/consent': typeof ConsentRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/timeline': typeof TimelineRouteWithChildren
|
||||
'/timelines': typeof TimelinesRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/consent': typeof ConsentRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/timeline': typeof TimelineRouteWithChildren
|
||||
'/timelines': typeof TimelinesRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRoute
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/timeline/$timelineId': typeof TimelineTimelineIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/mcp/$': typeof ApiMcpSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/consent'
|
||||
| '/dashboard'
|
||||
| '/demo'
|
||||
| '/login'
|
||||
| '/timeline'
|
||||
| '/timelines'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/oauth-protected-resource'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/timeline/$timelineId'
|
||||
| '/api/auth/$'
|
||||
| '/api/mcp/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/consent'
|
||||
| '/dashboard'
|
||||
| '/demo'
|
||||
| '/login'
|
||||
| '/timeline'
|
||||
| '/timelines'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/oauth-protected-resource'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/timeline/$timelineId'
|
||||
| '/api/auth/$'
|
||||
| '/api/mcp/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/consent'
|
||||
| '/dashboard'
|
||||
| '/demo'
|
||||
| '/login'
|
||||
| '/timeline'
|
||||
| '/timelines'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/oauth-protected-resource'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/timeline/$timelineId'
|
||||
| '/api/auth/$'
|
||||
| '/api/mcp/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ConsentRoute: typeof ConsentRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
DemoRoute: typeof DemoRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
TimelineRoute: typeof TimelineRouteWithChildren
|
||||
TimelinesRoute: typeof TimelinesRoute
|
||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRoute
|
||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiMcpSplatRoute: typeof ApiMcpSplatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -170,6 +251,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DashboardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/consent': {
|
||||
id: '/consent'
|
||||
path: '/consent'
|
||||
fullPath: '/consent'
|
||||
preLoaderRoute: typeof ConsentRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -184,6 +272,34 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof TimelineTimelineIdRouteImport
|
||||
parentRoute: typeof TimelineRoute
|
||||
}
|
||||
'/.well-known/openid-configuration': {
|
||||
id: '/.well-known/openid-configuration'
|
||||
path: '/.well-known/openid-configuration'
|
||||
fullPath: '/.well-known/openid-configuration'
|
||||
preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/.well-known/oauth-protected-resource': {
|
||||
id: '/.well-known/oauth-protected-resource'
|
||||
path: '/.well-known/oauth-protected-resource'
|
||||
fullPath: '/.well-known/oauth-protected-resource'
|
||||
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/.well-known/oauth-authorization-server': {
|
||||
id: '/.well-known/oauth-authorization-server'
|
||||
path: '/.well-known/oauth-authorization-server'
|
||||
fullPath: '/.well-known/oauth-authorization-server'
|
||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/mcp/$': {
|
||||
id: '/api/mcp/$'
|
||||
path: '/api/mcp/$'
|
||||
fullPath: '/api/mcp/$'
|
||||
preLoaderRoute: typeof ApiMcpSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/$': {
|
||||
id: '/api/auth/$'
|
||||
path: '/api/auth/$'
|
||||
@@ -191,6 +307,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/.well-known/oauth-authorization-server/api/auth': {
|
||||
id: '/.well-known/oauth-authorization-server/api/auth'
|
||||
path: '/api/auth'
|
||||
fullPath: '/.well-known/oauth-authorization-server/api/auth'
|
||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRouteImport
|
||||
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,14 +329,36 @@ const TimelineRouteWithChildren = TimelineRoute._addFileChildren(
|
||||
TimelineRouteChildren,
|
||||
)
|
||||
|
||||
interface DotwellKnownOauthAuthorizationServerRouteChildren {
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
|
||||
{
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute:
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute,
|
||||
}
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
||||
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
|
||||
DotwellKnownOauthAuthorizationServerRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ConsentRoute: ConsentRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
DemoRoute: DemoRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
TimelineRoute: TimelineRouteWithChildren,
|
||||
TimelinesRoute: TimelinesRoute,
|
||||
DotwellKnownOauthAuthorizationServerRoute:
|
||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||
DotwellKnownOauthProtectedResourceRoute:
|
||||
DotwellKnownOauthProtectedResourceRoute,
|
||||
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiMcpSplatRoute: ApiMcpSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@zendegi/auth";
|
||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
||||
|
||||
const handler = oauthProviderAuthServerMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/.well-known/oauth-authorization-server/api/auth"
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@zendegi/auth";
|
||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
||||
|
||||
const handler = oauthProviderAuthServerMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-authorization-server")(
|
||||
{
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "@zendegi/env/server";
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-protected-resource")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => {
|
||||
const baseUrl = env.BETTER_AUTH_URL.replace(/\/api\/auth$/, "");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resource: baseUrl,
|
||||
authorization_servers: [`${baseUrl}/api/auth`],
|
||||
bearer_methods_supported: ["header"],
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control":
|
||||
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
13
apps/web/src/routes/[.well-known]/openid-configuration.ts
Normal file
13
apps/web/src/routes/[.well-known]/openid-configuration.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@zendegi/auth";
|
||||
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
|
||||
const handler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/openid-configuration")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
316
apps/web/src/routes/api/mcp/$.ts
Normal file
316
apps/web/src/routes/api/mcp/$.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { mcpHandler } from "@better-auth/oauth-provider";
|
||||
import { env } from "@zendegi/env/server";
|
||||
import { db } from "@zendegi/db";
|
||||
import {
|
||||
timeline,
|
||||
timelineGroup,
|
||||
timelineItem,
|
||||
} from "@zendegi/db/schema/timeline";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
function createMcpServer(userId: string) {
|
||||
const server = new McpServer({
|
||||
name: "zendegi",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// --- Read tools ---
|
||||
|
||||
server.registerTool(
|
||||
"list_timelines",
|
||||
{
|
||||
description: "List the authenticated user's timelines",
|
||||
},
|
||||
async () => {
|
||||
const timelines = await db.query.timeline.findMany({
|
||||
where: eq(timeline.ownerId, userId),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(timelines) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_timeline",
|
||||
{
|
||||
description: "Get a timeline by ID, including its groups and items",
|
||||
inputSchema: { id: z.string().uuid().describe("Timeline ID") },
|
||||
},
|
||||
async ({ id }) => {
|
||||
const result = await db.query.timeline.findFirst({
|
||||
where: and(eq(timeline.id, id), eq(timeline.ownerId, userId)),
|
||||
with: {
|
||||
groups: {
|
||||
orderBy: (g, { asc }) => [asc(g.sortOrder)],
|
||||
with: {
|
||||
items: {
|
||||
orderBy: (i, { asc }) => [asc(i.start)],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Timeline not found" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Create tools ---
|
||||
|
||||
server.registerTool(
|
||||
"create_timeline",
|
||||
{
|
||||
description: "Create a new timeline",
|
||||
inputSchema: { title: z.string().min(1).describe("Timeline title") },
|
||||
},
|
||||
async ({ title }) => {
|
||||
const [created] = await db
|
||||
.insert(timeline)
|
||||
.values({ title, ownerId: userId })
|
||||
.returning();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(created) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"create_group",
|
||||
{
|
||||
description: "Create a group in a timeline",
|
||||
inputSchema: {
|
||||
title: z.string().min(1).describe("Group title"),
|
||||
timelineId: z.string().uuid().describe("Parent timeline ID"),
|
||||
},
|
||||
},
|
||||
async ({ title, timelineId }) => {
|
||||
// Verify ownership
|
||||
const t = await db.query.timeline.findFirst({
|
||||
where: and(eq(timeline.id, timelineId), eq(timeline.ownerId, userId)),
|
||||
});
|
||||
if (!t) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Timeline not found" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(timelineGroup)
|
||||
.values({ title, timelineId })
|
||||
.returning();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(created) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"create_item",
|
||||
{
|
||||
description: "Create an item in a group",
|
||||
inputSchema: {
|
||||
title: z.string().min(1).describe("Item title"),
|
||||
description: z.string().default("").describe("Item description"),
|
||||
start: z.string().describe("Start date (ISO 8601)"),
|
||||
end: z.string().optional().describe("End date (ISO 8601, optional)"),
|
||||
timelineGroupId: z.string().uuid().describe("Parent group ID"),
|
||||
},
|
||||
},
|
||||
async ({ title, description, start, end, timelineGroupId }) => {
|
||||
// Verify ownership through the group's timeline
|
||||
const group = await db.query.timelineGroup.findFirst({
|
||||
where: eq(timelineGroup.id, timelineGroupId),
|
||||
with: { timeline: true },
|
||||
});
|
||||
if (!group || group.timeline.ownerId !== userId) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Group not found" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(timelineItem)
|
||||
.values({
|
||||
title,
|
||||
description,
|
||||
start: new Date(start),
|
||||
end: end ? new Date(end) : undefined,
|
||||
timelineGroupId,
|
||||
})
|
||||
.returning();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(created) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Update tools ---
|
||||
|
||||
server.registerTool(
|
||||
"update_item",
|
||||
{
|
||||
description: "Update an existing item's fields",
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe("Item ID"),
|
||||
title: z.string().min(1).optional().describe("New title"),
|
||||
description: z.string().optional().describe("New description"),
|
||||
start: z.string().optional().describe("New start date (ISO 8601)"),
|
||||
end: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("New end date (ISO 8601, or null to remove)"),
|
||||
},
|
||||
},
|
||||
async ({ id, title, description, start, end }) => {
|
||||
// Verify ownership through item → group → timeline
|
||||
const item = await db.query.timelineItem.findFirst({
|
||||
where: eq(timelineItem.id, id),
|
||||
with: { timelineGroup: { with: { timeline: true } } },
|
||||
});
|
||||
if (!item || item.timelineGroup.timeline.ownerId !== userId) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Item not found" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (start !== undefined) updates.start = new Date(start);
|
||||
if (end !== undefined) updates.end = end ? new Date(end) : null;
|
||||
|
||||
const [updated] = await db
|
||||
.update(timelineItem)
|
||||
.set(updates)
|
||||
.where(eq(timelineItem.id, id))
|
||||
.returning();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(updated) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Delete tools ---
|
||||
|
||||
server.registerTool(
|
||||
"delete_timeline",
|
||||
{
|
||||
description: "Delete a timeline and all its groups and items (cascades)",
|
||||
inputSchema: { id: z.string().uuid().describe("Timeline ID") },
|
||||
},
|
||||
async ({ id }) => {
|
||||
const deleted = await db
|
||||
.delete(timeline)
|
||||
.where(and(eq(timeline.id, id), eq(timeline.ownerId, userId)))
|
||||
.returning();
|
||||
if (deleted.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Timeline not found" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Timeline deleted" }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_group",
|
||||
{
|
||||
description: "Delete a group and all its items (cascades)",
|
||||
inputSchema: { id: z.string().uuid().describe("Group ID") },
|
||||
},
|
||||
async ({ id }) => {
|
||||
// Verify ownership
|
||||
const group = await db.query.timelineGroup.findFirst({
|
||||
where: eq(timelineGroup.id, id),
|
||||
with: { timeline: true },
|
||||
});
|
||||
if (!group || group.timeline.ownerId !== userId) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Group not found" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
await db.delete(timelineGroup).where(eq(timelineGroup.id, id));
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Group deleted" }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_item",
|
||||
{
|
||||
description: "Delete an item",
|
||||
inputSchema: { id: z.string().uuid().describe("Item ID") },
|
||||
},
|
||||
async ({ id }) => {
|
||||
// Verify ownership
|
||||
const item = await db.query.timelineItem.findFirst({
|
||||
where: eq(timelineItem.id, id),
|
||||
with: { timelineGroup: { with: { timeline: true } } },
|
||||
});
|
||||
if (!item || item.timelineGroup.timeline.ownerId !== userId) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Item not found" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
await db.delete(timelineItem).where(eq(timelineItem.id, id));
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Item deleted" }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const handler = mcpHandler(
|
||||
{
|
||||
jwksUrl: `${env.BETTER_AUTH_URL}/api/auth/jwks`,
|
||||
verifyOptions: {
|
||||
issuer: `${env.BETTER_AUTH_URL}/api/auth`,
|
||||
audience: [env.BETTER_AUTH_URL, new URL(env.BETTER_AUTH_URL).href],
|
||||
},
|
||||
},
|
||||
async (req, jwt) => {
|
||||
const userId = jwt.sub;
|
||||
if (!userId) {
|
||||
return new Response("Missing subject in token", { status: 401 });
|
||||
}
|
||||
const server = createMcpServer(userId);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
await server.connect(transport);
|
||||
return transport.handleRequest(req);
|
||||
}
|
||||
);
|
||||
|
||||
export const Route = createFileRoute("/api/mcp/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
POST: ({ request }) => handler(request),
|
||||
DELETE: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
54
apps/web/src/routes/consent.tsx
Normal file
54
apps/web/src/routes/consent.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const Route = createFileRoute("/consent")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
client_id: (search.client_id as string) ?? "",
|
||||
scope: (search.scope as string) ?? "",
|
||||
}),
|
||||
component: ConsentComponent,
|
||||
});
|
||||
|
||||
function ConsentComponent() {
|
||||
const { client_id, scope } = useSearch({ from: "/consent" });
|
||||
const scopes = scope.split(" ").filter(Boolean);
|
||||
|
||||
const handleAccept = async () => {
|
||||
await authClient.oauth2.consent({ accept: true });
|
||||
};
|
||||
|
||||
const handleDeny = async () => {
|
||||
await authClient.oauth2.consent({ accept: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="container mx-auto max-w-md px-4 py-12">
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-serif font-bold">Authorize Application</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<strong>{client_id}</strong> is requesting access to your account.
|
||||
</p>
|
||||
|
||||
{scopes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Requested permissions:</p>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{scopes.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleAccept}>Allow</Button>
|
||||
<Button variant="outline" onClick={handleDeny}>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user