Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ kitty-specs/**/tasks/*.md
!kitty-specs/009-automated-pipelines-framework/tasks/WP08-api-mcp-tools.md
!kitty-specs/009-automated-pipelines-framework/tasks/WP09-analytics.md
!kitty-specs/009-automated-pipelines-framework/tasks/WP10-integration-validation.md
!kitty-specs/011-inngest-migration/tasks/WP01-port-remaining-pipelines.md
!kitty-specs/011-inngest-migration/tasks/WP02-update-routes.md
!kitty-specs/011-inngest-migration/tasks/WP03-delete-custom-plumbing.md
!kitty-specs/011-inngest-migration/tasks/WP04-integration-tests.md

# Generated artifacts
**/tmp/
Expand Down
54 changes: 52 additions & 2 deletions .kittify/memory/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,55 @@ This list is not exhaustive — the skill and tenant configuration system is des
- Graceful degradation over hard failures
- Human handoff for critical failures

### 5.4 MCP Architecture

**Background**: Each MCP tool schema costs 400–1,400 tokens to load into a client's context window. The platform currently ships ~80 tools across three servers; at ~600 tokens average, a monolithic client session would consume ~48K tokens of context before a single user message. Anthropic's Tool Search (shipped January 2026) mitigates this by deferring all tool definitions above 10K tokens and replacing them with a single semantic-search tool — clients load only 3–5 relevant tools per turn. Every client connecting to joyus-ai already triggers Tool Search. These principles ensure the platform stays compatible with that mechanism and avoids re-creating the problem internally.

#### Four-server boundary

| Server | Tools | Availability | Notes |
|--------|-------|--------------|-------|
| `joyus-ai-platform` | `content_*`, `pipeline_*`, `ops_*`, future `governance_*` | Always loaded, tenant-scoped | Platform brain — no external auth required |
| `joyus-ai-integrations` | `jira_*`, `github_*`, `slack_*`, `google_*`, future connectors | Connection-gated per integration | Currently co-located in `joyus-ai-mcp-server`; split when integration count warrants |
| `joyus-ai-profile` | `build_profile`, `verify_content`, `identify_author`, `check_drift`, etc. | Always loaded, tenant-scoped | Already separate (Python) |
| `joyus-ai-state` | `get_context`, `run_gates`, `verify_branch`, etc. | Dev/local only | Already separate; not shipped to end clients |

**Current state**: Platform Core and Integrations are co-located in `joyus-ai-mcp-server` (55 tools). Tool Search handles the context tax. Split into separate servers when: (a) integration count exceeds 6–8 connectors, or (b) a client use case needs platform tools but never integration tools.

#### Domain prefixing is mandatory

Every tool has a domain prefix that determines its server home. No "misc" tools — new domains get new prefixes before any tools are added.

| Prefix | Domain | Server |
|--------|--------|--------|
| `content_` | Content infrastructure | Platform |
| `pipeline_` | Automated pipelines | Platform |
| `governance_` | Org-scale governance (Spec 007) | Platform |
| `jira_` | Jira integration | Integrations |
| `github_` | GitHub integration | Integrations |
| `slack_` | Slack integration | Integrations |
| `google_` | Google Workspace integration | Integrations |

#### Tool descriptions are search-optimized

Tool descriptions must encode *use-case keywords*, not just "what it does" prose. Tool Search selects tools via semantic search — descriptions that read like marketing copy fail; descriptions that name the task the agent is trying to accomplish succeed.

Good: `"Create a new automated pipeline for a tenant. Use when an operator or agent needs to define a trigger-to-step-sequence workflow, schedule recurring jobs, or set up corpus-change automation."`

Bad: `"Creates a pipeline object in the database with the specified configuration."`

Write descriptions as if answering: *"What would a Claude agent type into a search box to find this tool?"*

#### No MCP loading for internal integrations

External service integrations used by pipeline step handlers (Slack notifications, email delivery, webhook calls, profile generation) are implemented as **TypeScript service interfaces with direct HTTP/SDK calls** — not as MCP server tool loading. MCP is a client-facing surface; it is not an internal integration mechanism.

Rationale: loading a Slack MCP server internally would add 15,000+ tokens to every pipeline execution context. The TypeScript interface + null client stub pattern (already established in Spec 009) is the correct approach. This rule applies to all future step handler integrations.

#### Per-agent MCP isolation requires custom orchestration

The Anthropic Agent SDK's `AgentDefinition` has no `mcp_servers` field — sub-agents inherit the parent's MCP configuration and can only filter it, not extend it. When building orchestration layers where different sub-agents need different MCP connections (e.g., a Jira sub-agent and a Slack sub-agent in the same pipeline), use **separate `query()` calls with independent `ClaudeAgentOptions`** per sub-agent rather than the built-in `Agent` tool delegation. This achieves true MCP isolation at the cost of manual orchestration code.

---

## 6. Quality Standards
Expand Down Expand Up @@ -297,9 +346,10 @@ This constitution can be amended by:

---

*Constitution Version: 1.6*
*Constitution Version: 1.7*
*Established: January 29, 2026*
*Last Updated: February 19, 2026*
*Last Updated: March 19, 2026*
*Changes v1.7: Added §5.4 "MCP Architecture" — four-server domain boundary (Platform Core, Integrations, Profile Engine, Dev Enforcement); mandatory domain prefixing table; Tool Search-optimized description guidelines; rule against MCP loading for internal integrations; per-agent MCP isolation pattern for orchestration layers*
*Changes v1.6: Added §2.10 "Client-Informed, Platform-Generic" — client needs inform abstract platform capabilities; no client names, terminology, or domain-specific examples in the public repo; agents must generalize at point of creation*
*Changes v1.5: Open source audience rewrite — generalized "Zivtech" to "deploying organization" throughout; renamed §2.2 from "Skills as Guardrails" to "Skills as Encoded Knowledge" (skills now include operational context, business rules, report definitions, compliance, not just constraints); renamed §2.6 from "Claude Code Alternative" to "Mediated AI Access" (model-agnostic framing); added single-org and multi-org deployment models; added first validated use case (ice cream manufacturer/distributor/retailer) and early use case breadth (healthcare, legal, higher ed, museum, assessment/credentialing); expanded stakeholders to include open source community and deploying organizations; broadened monitoring to include operational accuracy; added skill ecosystem concept (community packs, archetype packs); added §3.2 Compliance Framework Awareness (HIPAA, FERPA, attorney-client privilege, assessment integrity, FDA/USDA) with hard-failure enforcement; updated Section 8 for multi-industry, model-agnostic positioning*
*Changes v1.4: Added Principle 2.8 (Open Source by Default) with repository separation model; added Principle 2.9 (Assumption Awareness) for proactive tracking of design assumptions; updated Section 8 to reflect open source posture; removed "consumer product" constraint (open source inherently broadens audience)*
Expand Down
33 changes: 32 additions & 1 deletion joyus-ai-mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion joyus-ai-mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@
"dotenv": "^16.4.1",
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
"express-rate-limit": "^8.3.1",
"express-session": "^1.17.3",
"helmet": "^7.1.0",
"inngest": "^3.0.0",
"jose": "^6.1.3",
"luxon": "^3.4.4",
"inngest": "^3.0.0",
"node-cron": "^3.0.3",
"pg": "^8.18.0",
"zod": "^3.22.0"
Expand Down
10 changes: 5 additions & 5 deletions joyus-ai-mcp-server/src/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
</div>
</div>
${connectedServices.has('GOOGLE')
? '<a href="/auth/google/disconnect" class="btn disconnect">Disconnect</a>'
? '<form method="POST" action="/auth/google/disconnect" style="display:inline"><button class="btn disconnect">Disconnect</button></form>'
: '<a href="/auth/google/start" class="btn">Connect</a>'}
</div>

Expand All @@ -177,7 +177,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
</div>
</div>
${connectedServices.has('JIRA')
? '<a href="/auth/jira/disconnect" class="btn disconnect">Disconnect</a>'
? '<form method="POST" action="/auth/jira/disconnect" style="display:inline"><button class="btn disconnect">Disconnect</button></form>'
: '<a href="/auth/jira/start" class="btn">Connect</a>'}
</div>

Expand All @@ -189,7 +189,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
</div>
</div>
${connectedServices.has('SLACK')
? '<a href="/auth/slack/disconnect" class="btn disconnect">Disconnect</a>'
? '<form method="POST" action="/auth/slack/disconnect" style="display:inline"><button class="btn disconnect">Disconnect</button></form>'
: '<a href="/auth/slack/start" class="btn">Connect</a>'}
</div>

Expand All @@ -201,7 +201,7 @@ authRouter.get('/', async (req: Request, res: Response) => {
</div>
</div>
${connectedServices.has('GITHUB')
? '<a href="/auth/github/disconnect" class="btn disconnect">Disconnect</a>'
? '<form method="POST" action="/auth/github/disconnect" style="display:inline"><button class="btn disconnect">Disconnect</button></form>'
: '<a href="/auth/github/start" class="btn">Connect</a>'}
</div>

Expand Down Expand Up @@ -652,7 +652,7 @@ authRouter.get('/github/callback', async (req: Request, res: Response) => {
// Disconnect & Logout
// ============================================================

authRouter.get('/:service/disconnect', requireSessionOrRedirect, async (req: Request, res: Response) => {
authRouter.post('/:service/disconnect', requireSessionOrRedirect, async (req: Request, res: Response) => {
const userId = req.session!.userId!;

const service = req.params.service.toUpperCase() as Service;
Expand Down
33 changes: 33 additions & 0 deletions joyus-ai-mcp-server/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Environment configuration validation
*
* Validates required env vars at import time using Zod.
* Import this module early in the application entry point.
*/

import { z } from 'zod';

const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().optional(),
SESSION_SECRET: z.string().optional(),
TOKEN_ENCRYPTION_KEY: z.string().optional(),
INNGEST_EVENT_KEY: z.string().optional(),
INNGEST_SIGNING_KEY: z.string().optional(),
});

// Validate at import time
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
console.error('[joyus] Invalid environment configuration:');
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
if (process.env.NODE_ENV === 'production') {
throw new Error('FATAL: Invalid environment configuration');
}
}

export const config = parsed.success ? parsed.data : envSchema.parse({});
4 changes: 2 additions & 2 deletions joyus-ai-mcp-server/src/content/generation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { drizzle } from 'drizzle-orm/node-postgres';
import { createId } from '@paralleldrive/cuid2';
import { contentGenerationLogs, contentOperationLogs } from '../schema.js';
import type { ResolvedEntitlements, GenerationResult } from '../types.js';
import { ContentRetriever, type SearchService, type RetrievalResult, type RetrievedItem } from './retriever.js';
import { ContentRetriever, type RetrievalResult, type RetrievedItem } from './retriever.js';
import type { SearchService } from '../search/index.js';
import {
ContentGenerator,
PlaceholderGenerationProvider,
Expand Down Expand Up @@ -110,7 +111,6 @@ export class GenerationService {
// Re-exports so callers can import everything from this module
export {
ContentRetriever,
type SearchService,
type RetrievalResult,
type RetrievedItem,
} from './retriever.js';
Expand Down
28 changes: 8 additions & 20 deletions joyus-ai-mcp-server/src/content/generation/retriever.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/**
* ContentRetriever — fetches relevant content items for generation.
*
* Filters accessible sources by entitlements, runs a search, then hydrates
* full item bodies from the database for context assembly.
* Delegates search to the real SearchService (entitlement-filtered FTS),
* then hydrates full item bodies from the database for context assembly.
*/

import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
import { contentItems } from '../schema.js';
import type { ResolvedEntitlements, SearchResult } from '../types.js';
import type { ResolvedEntitlements } from '../types.js';
import type { SearchService } from '../search/index.js';

type DrizzleClient = ReturnType<typeof drizzle>;

Expand All @@ -26,14 +27,6 @@ export interface RetrievedItem {
metadata: Record<string, unknown>;
}

export interface SearchService {
search(
query: string,
accessibleSourceIds: string[],
options?: { limit?: number },
): Promise<SearchResult[]>;
}

export class ContentRetriever {
constructor(
private searchService: SearchService,
Expand All @@ -45,18 +38,13 @@ export class ContentRetriever {
entitlements: ResolvedEntitlements,
options?: { sourceIds?: string[]; maxSources?: number },
): Promise<RetrievalResult> {
// 1. Filter sourceIds by entitlements
const accessibleSourceIds = options?.sourceIds
? options.sourceIds.filter(id => entitlements.sourceIds.includes(id))
: entitlements.sourceIds;

// 2. Search via SearchService
// 1. Search via SearchService (handles entitlement → sourceId resolution internally)
const maxSources = options?.maxSources ?? 5;
const results = await this.searchService.search(query, accessibleSourceIds, {
const results = await this.searchService.search(query, entitlements, {
limit: maxSources,
});

// 3. Fetch full content for each result
// 2. Fetch full content for each result
const items: RetrievedItem[] = [];
for (const result of results) {
const rows = await this.db
Expand All @@ -77,7 +65,7 @@ export class ContentRetriever {
}
}

// 4. Format context text with numbered source labels
// 3. Format context text with numbered source labels
const contextText = items
.map((item, i) => `[Source ${i + 1}: "${item.title}"] ${item.body}`)
.join('\n\n');
Expand Down
16 changes: 13 additions & 3 deletions joyus-ai-mcp-server/src/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import type { DrizzleClient } from './types.js';
import { connectorRegistry } from './connectors/index.js';
import { PgFtsProvider, SearchService } from './search/index.js';
import { EntitlementCache, EntitlementService, HttpEntitlementResolver } from './entitlements/index.js';
import { GenerationService, PlaceholderGenerationProvider, type SearchService as GenSearchService } from './generation/index.js';
import { GenerationService, PlaceholderGenerationProvider } from './generation/index.js';
import { setContentContext } from '../tools/executor.js';
import { SyncEngine, initializeSyncScheduler } from './sync/index.js';
import { HealthChecker } from './monitoring/health.js';
import { MetricsCollector } from './monitoring/metrics.js';
Expand All @@ -35,8 +36,17 @@ export async function initializeContentModule(
const searchProvider = new PgFtsProvider(db);
const searchService = new SearchService(searchProvider, db);

// Inject SearchService into content tool executor
setContentContext({ searchService });

// 2. Entitlements
const entitlementCache = new EntitlementCache();

// Clean up expired cache entries every 5 minutes
const cacheCleanupInterval = setInterval(() => {
entitlementCache.cleanup();
}, 5 * 60 * 1000);
cacheCleanupInterval.unref();
const entitlementResolver = new HttpEntitlementResolver({
name: 'default',
defaultTtlSeconds: 300,
Expand All @@ -50,10 +60,10 @@ export async function initializeContentModule(
});
const entitlementService = new EntitlementService(entitlementResolver, entitlementCache, db);

// 3. Generation (bridge search service to generation's expected interface)
// 3. Generation
const generationProvider = new PlaceholderGenerationProvider();
const generationService = new GenerationService(
searchService as unknown as GenSearchService,
searchService,
generationProvider,
db,
);
Expand Down
Loading
Loading