Skip to content

Commit 6ace9a2

Browse files
authored
Merge branch 'main' into dev2
2 parents 2f85eb2 + 2433bb6 commit 6ace9a2

18 files changed

Lines changed: 560 additions & 67 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,18 @@ For `shared/`, `docs/`, `SparkyFitnessMCP/`, and `SparkyFitnessGarmin/`, there i
2929
- `SparkyFitnessMobile/` - Expo SDK 54 / React Native 0.81 app.
3030
- `shared/` - source-first TypeScript workspace package for `@workspace/shared` schemas, constants, and timezone/day helpers.
3131
- `docs/` - Nuxt / Docus docs site.
32-
- `SparkyFitnessMCP/` - TypeScript MCP server integrated into the `pnpm` workspace.
32+
- `SparkyFitnessMCP/` - TypeScript MCP server in the `pnpm` workspace. **Deprecated** in favor of the in-process `/mcp` route on `SparkyFitnessServer`; the `codewithcj/sparkyfitness_mcp:latest` image is still published during a deprecation window, so the package stays for now.
3333
- `SparkyFitnessGarmin/` - standalone Python integration service outside the current `pnpm` workspace.
3434
- `docker/`, `helm/`, `.github/` - infra and deployment assets.
3535
- `db_schema_backup.sql` - repo-root schema snapshot that should stay aligned with server migrations.
3636
- `docker/.env.example` - tracked env template commonly copied to repo-root `.env`.
3737

3838
## Workspace Notes
3939

40-
- `pnpm-workspace.yaml` currently lists `frontend`, `SparkyFitnessFrontend`, `shared`, `SparkyFitnessMobile`, `SparkyFitnessServer`, and `docs`.
40+
- `pnpm-workspace.yaml` currently lists `frontend`, `SparkyFitnessFrontend`, `shared`, `SparkyFitnessMobile`, `SparkyFitnessServer`, `docs`, and `SparkyFitnessMCP`.
4141
- Only `SparkyFitnessFrontend/` exists on disk right now; treat `frontend` as a legacy workspace entry unless the task is specifically about workspace cleanup.
4242
- `shared/` is a library package, not an app. Validate shared changes from the consuming package(s), not in isolation.
43-
- `SparkyFitnessMCP/` and `SparkyFitnessGarmin/` are outside the current workspace, so inspect their own manifests and scripts before working there.
43+
- `SparkyFitnessMCP/` is still listed in `pnpm-workspace.yaml` (it is deprecated; Phase 3b will remove the entry). `SparkyFitnessGarmin/` is outside the current workspace. Inspect their own manifests and scripts before working there.
4444

4545
## Cross-Package Rules
4646

SparkyFitnessFrontend/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export default defineConfig(({ mode }) => {
2323
target: target,
2424
changeOrigin: true,
2525
},
26+
'/mcp': {
27+
target: target,
28+
changeOrigin: true,
29+
},
2630
'/uploads': {
2731
target: target,
2832
changeOrigin: true,

SparkyFitnessMCP/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# SparkyFitness MCP Server (DEPRECATED)
2+
3+
> **DEPRECATED:** This standalone MCP server is superseded by the in-process
4+
> `/mcp` route on the main `SparkyFitnessServer`. New integrations should point
5+
> at the main server's `/mcp` endpoint. This package and its Dockerfiles remain
6+
> only during a deprecation window and will be removed in a scheduled follow-up.
7+
8+
## What changed
9+
10+
MCP is now served in-process by the main `SparkyFitnessServer` at `POST /mcp`.
11+
That route reuses the chatbot tool registry, so the server and the assistant
12+
expose a single, shared tool surface instead of two separate implementations.
13+
14+
This standalone package previously ran MCP as its own service (HTTP and stdio).
15+
It is no longer the recommended way to run MCP.
16+
17+
## Where to point MCP clients
18+
19+
Use the main server's `/mcp` endpoint:
20+
21+
- Production: `https://<your-host>/mcp`
22+
- Local dev: `http://localhost:8080/mcp` (the frontend Vite dev proxy forwards
23+
`/mcp` to the server; `http://localhost:3010/mcp` hits the server directly)
24+
25+
stdio-only MCP clients (that cannot speak Streamable HTTP) can bridge to the
26+
HTTP endpoint with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
27+
28+
```bash
29+
npx mcp-remote https://<your-host>/mcp
30+
```
31+
32+
## Why this package still exists
33+
34+
The Docker image `codewithcj/sparkyfitness_mcp:latest` is still published during
35+
the deprecation window, so this package and its Dockerfiles
36+
(`docker/Dockerfile.mcp`, `docker/Dockerfile.mcp.dev`) remain in the repo for
37+
now. Existing deployments that depend on the standalone image keep working until
38+
the window closes.
39+
40+
## Removal
41+
42+
Removal of this package, its Dockerfiles, and the CI build/publish steps is
43+
tracked as a scheduled follow-up (Phase 3b), at the upstream maintainer's
44+
discretion.

SparkyFitnessMCP/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
// ─── DEPRECATED ───────────────────────────────────────────────────────────────
2+
// This standalone MCP server is superseded by the in-process `/mcp` route on the
3+
// main SparkyFitnessServer, which reuses the chatbot tool registry (one shared
4+
// tool surface). Point new MCP clients at the main server's `/mcp` endpoint
5+
// instead. The Docker image `codewithcj/sparkyfitness_mcp:latest` is still
6+
// published during a deprecation window, so this package remains for now;
7+
// removal is tracked as a scheduled follow-up (Phase 3b). See README.md.
8+
// ──────────────────────────────────────────────────────────────────────────────
9+
110
import path from "path";
211
import dotenv from "dotenv";
312

SparkyFitnessServer/ai/mcp/mcpAdapter.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import type { ToolCallOptions } from 'ai';
33
import { buildChatbotTools } from '../tools/index.js';
4+
import { buildDevTools } from '../tools/devTools.js';
45

56
// Registry handlers read only rawArgs (no abortSignal/messages), so a stub
67
// satisfies the execute() signature.
@@ -16,18 +17,13 @@ interface RegistryTool {
1617
execute?: (args: unknown, options: ToolCallOptions) => Promise<any> | any;
1718
}
1819

19-
// Re-publishes the in-process chatbot tool registry as MCP tools, reusing each
20-
// tool's zod-4 schema and execute() so MCP clients and the chatbot share one
21-
// surface with identical output text.
22-
export function registerRegistryTools(
20+
// Registers a name->tool map onto an McpServer, reusing each tool's zod-4 schema
21+
// and execute(). Shared by the registry and dev-tool registration so both wrap
22+
// the plain-string return into MCP { content: [...] } identically.
23+
function registerToolMap(
2324
mcpServer: McpServer,
24-
userId: string,
25-
tz: string
25+
tools: Record<string, RegistryTool>
2626
): void {
27-
const tools = buildChatbotTools(userId, tz) as unknown as Record<
28-
string,
29-
RegistryTool
30-
>;
3127
for (const [name, t] of Object.entries(tools)) {
3228
mcpServer.registerTool(
3329
name,
@@ -43,3 +39,29 @@ export function registerRegistryTools(
4339
);
4440
}
4541
}
42+
43+
// Re-publishes the in-process chatbot tool registry as MCP tools, reusing each
44+
// tool's zod-4 schema and execute() so MCP clients and the chatbot share one
45+
// surface with identical output text.
46+
export function registerRegistryTools(
47+
mcpServer: McpServer,
48+
userId: string,
49+
tz: string
50+
): void {
51+
const tools = buildChatbotTools(userId, tz) as unknown as Record<
52+
string,
53+
RegistryTool
54+
>;
55+
registerToolMap(mcpServer, tools);
56+
}
57+
58+
// Registers the admin-only dev tools (kept out of buildChatbotTools so the
59+
// chatbot never sees them). The route gates this on DEV_TOOLS_ENABLED + an admin
60+
// caller, so non-admins never get these in tools/list.
61+
export function registerDevTools(mcpServer: McpServer, userId: string): void {
62+
const tools = buildDevTools(userId) as unknown as Record<
63+
string,
64+
RegistryTool
65+
>;
66+
registerToolMap(mcpServer, tools);
67+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { tool } from 'ai';
2+
import { z } from 'zod';
3+
import { log } from '../../config/logging.js';
4+
import { getSystemClient, getPoolStats } from '../../db/poolManager.js';
5+
import { resolveIsAdmin } from '../../utils/adminCheck.js';
6+
import { ERRORS, formatZodError } from './errors.js';
7+
import { formatSuccess } from './formatting.js';
8+
9+
const inspectSchemaInput = z.object({
10+
table: z.string().min(1).describe('Name of the database table to inspect'),
11+
});
12+
13+
const emptyInput = z.object({});
14+
15+
// Defense-in-depth gate re-checked on every call (the route already gates
16+
// registration). Re-verifies the env flag and admin role via the DB lookup,
17+
// since the handler closes over only a userId. Returns an ERRORS.* string when
18+
// denied, null when allowed.
19+
async function assertDevAccess(userId: string): Promise<string | null> {
20+
if (process.env.DEV_TOOLS_ENABLED !== 'true') {
21+
return ERRORS.FORBIDDEN('Dev tools are disabled');
22+
}
23+
if (!(await resolveIsAdmin(undefined, userId))) {
24+
return ERRORS.FORBIDDEN('Admin access required');
25+
}
26+
return null;
27+
}
28+
29+
// The 4 admin/debug tools, kept out of buildChatbotTools so the chatbot never
30+
// sees them; registered only for an admin when DEV_TOOLS_ENABLED=true. Each
31+
// execute() returns a plain string — registerToolMap does the MCP wrapping.
32+
export function buildDevTools(userId: string) {
33+
return {
34+
sparky_inspect_schema: tool({
35+
description:
36+
'Inspect the database schema to understand available tables and columns. Requires admin access and DEV_TOOLS_ENABLED=true.',
37+
inputSchema: inspectSchemaInput,
38+
execute: async (rawArgs) => {
39+
const denied = await assertDevAccess(userId);
40+
if (denied) return denied;
41+
42+
const parsed = inspectSchemaInput.safeParse(rawArgs);
43+
if (!parsed.success) {
44+
return formatZodError(parsed.error);
45+
}
46+
const { table } = parsed.data;
47+
48+
const client = await getSystemClient();
49+
try {
50+
let schema = 'public';
51+
let tableName = table;
52+
if (table.includes('.')) {
53+
const parts = table.split('.');
54+
schema = parts[0];
55+
tableName = parts[1];
56+
}
57+
58+
const result = await client.query(
59+
`SELECT column_name, data_type, is_nullable, column_default, table_schema
60+
FROM information_schema.columns
61+
WHERE table_name = $1 AND table_schema = $2
62+
ORDER BY ordinal_position`,
63+
[tableName, schema]
64+
);
65+
66+
if (result.rows.length === 0) {
67+
return ERRORS.NOT_FOUND('Table', table);
68+
}
69+
70+
const columns = result.rows.map((row: any) => ({
71+
column: row.column_name,
72+
type: row.data_type,
73+
nullable: row.is_nullable === 'YES',
74+
default: row.column_default,
75+
}));
76+
77+
return formatSuccess(
78+
{ table, columns, column_count: columns.length },
79+
`Schema: ${table}`
80+
);
81+
} catch (error) {
82+
log('error', '[Dev Tool] inspectSchema error:', error);
83+
return ERRORS.DB_ERROR();
84+
} finally {
85+
client.release();
86+
}
87+
},
88+
}),
89+
90+
sparky_get_user_info: tool({
91+
description:
92+
'Get information about the current authenticated user. Requires admin access and DEV_TOOLS_ENABLED=true.',
93+
inputSchema: emptyInput,
94+
execute: async () => {
95+
const denied = await assertDevAccess(userId);
96+
if (denied) return denied;
97+
98+
const client = await getSystemClient();
99+
try {
100+
const result = await client.query(
101+
`SELECT id, name, email, role, created_at, updated_at
102+
FROM "user"
103+
WHERE id = $1`,
104+
[userId]
105+
);
106+
107+
if (result.rows.length === 0) {
108+
return ERRORS.NOT_FOUND('User', userId);
109+
}
110+
111+
const user = result.rows[0];
112+
return formatSuccess(
113+
{ user_id: userId, ...user },
114+
'Current User Info'
115+
);
116+
} catch (error) {
117+
log('error', '[Dev Tool] getUserInfo error:', error);
118+
return ERRORS.DB_ERROR();
119+
} finally {
120+
client.release();
121+
}
122+
},
123+
}),
124+
125+
sparky_get_db_stats: tool({
126+
description:
127+
'Get current database connection pool statistics. Requires admin access and DEV_TOOLS_ENABLED=true.',
128+
inputSchema: emptyInput,
129+
execute: async () => {
130+
const denied = await assertDevAccess(userId);
131+
if (denied) return denied;
132+
133+
try {
134+
return formatSuccess(getPoolStats(), 'Database Pool Stats');
135+
} catch (error) {
136+
log('error', '[Dev Tool] getDbStats error:', error);
137+
return ERRORS.DB_ERROR();
138+
}
139+
},
140+
}),
141+
142+
sparky_run_project_tests: tool({
143+
description:
144+
"Run the project's test suite to verify nutrition and fitness logic. Requires admin access and DEV_TOOLS_ENABLED=true.",
145+
inputSchema: emptyInput,
146+
execute: async () => {
147+
const denied = await assertDevAccess(userId);
148+
if (denied) return denied;
149+
150+
return formatSuccess(
151+
{
152+
status: 'scheduled',
153+
message:
154+
'Tests would be executed via child_process in a real environment.',
155+
},
156+
'Project Tests'
157+
);
158+
},
159+
}),
160+
};
161+
}

SparkyFitnessServer/db/poolManager.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ async function getSystemClient() {
8282
const client = await _getRawOwnerPool().connect();
8383
return client;
8484
}
85+
// node-pg counters for the app pool, for the admin-only dev tools. Guards
86+
// against an uninitialized pool.
87+
function getPoolStats() {
88+
if (!appPoolInstance) {
89+
return { totalCount: 0, idleCount: 0, waitingCount: 0 };
90+
}
91+
return {
92+
totalCount: appPoolInstance.totalCount,
93+
idleCount: appPoolInstance.idleCount,
94+
waitingCount: appPoolInstance.waitingCount,
95+
};
96+
}
8597
async function endPool() {
8698
if (ownerPoolInstance) {
8799
log('info', 'Ending existing owner database connection pool...');
@@ -110,11 +122,13 @@ export { endPool };
110122
export { resetPool };
111123
export { getClient };
112124
export { getSystemClient };
125+
export { getPoolStats };
113126
export { _getRawOwnerPool as getRawOwnerPool };
114127
export default {
115128
endPool,
116129
resetPool,
117130
getClient,
118131
getSystemClient,
132+
getPoolStats,
119133
getRawOwnerPool: _getRawOwnerPool,
120134
};

SparkyFitnessServer/middleware/authMiddleware.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import userRepository from '../models/userRepository.js';
33
import { serializeSignedCookie } from 'better-call';
44
import { auth } from '../auth.js';
55
import { canAccessUserData } from '../utils/permissionUtils.js';
6+
import { resolveIsAdmin } from '../utils/adminCheck.js';
67
import {
78
getCachedSession,
89
setCachedSession,
@@ -176,26 +177,12 @@ const isAdmin = async (req: any, res: any, next: any) => {
176177
if (!req.userId) {
177178
return res.status(401).json({ error: 'Authentication required.' });
178179
}
179-
// 1. Super-admin override
180-
if (
181-
process.env.SPARKY_FITNESS_ADMIN_EMAIL &&
182-
req.user?.email === process.env.SPARKY_FITNESS_ADMIN_EMAIL
183-
) {
180+
// Admin predicate lives in utils/adminCheck.ts (checks the AUTHENTICATED user,
181+
// never the context-switched one, to prevent privilege escalation).
182+
if (await resolveIsAdmin(req.user, req.authenticatedUserId)) {
184183
return next();
185184
}
186-
// 2. Native Better Auth Role Check
187-
// We MUST check the role of the AUTHENTICATED user, not the ACTIVE user
188-
// to prevent privilege escalation via context switching.
189-
const userRole =
190-
req.user?.role ||
191-
(await userRepository.getUserRole(req.authenticatedUserId));
192-
if (userRole === 'admin') {
193-
return next();
194-
}
195-
log(
196-
'warn',
197-
`Admin Check: Access denied for User ${req.userId} (Role: ${userRole})`
198-
);
185+
log('warn', `Admin Check: Access denied for User ${req.userId}`);
199186
return res.status(403).json({ error: 'Admin access required.' });
200187
};
201188
export { authenticate };

SparkyFitnessServer/models/chatRepository.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,6 @@ async function clearAllChatHistory(userId: string) {
314314
async function saveChatHistory(
315315
historyData: Partial<SparkyChatHistory> & {
316316
messageType?: 'user' | 'assistant';
317-
parts?: any[];
318317
}
319318
) {
320319
const client = await getClient(historyData.user_id); // User-specific operation

0 commit comments

Comments
 (0)