diff --git a/AGENTS.md b/AGENTS.md index 11e5343..0f8280b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Mounted at `/mcp`. Resources include: - Allocation rule preview - Schedule E line summary -Authentication: verified ChittyAuth bearer JWT for end-user/agent callers; legacy internal service-token callers remain supported with explicit `X-Chitty-User-Id`. Tenant authorization still comes from `tenant_users`. See [SECURITY.md](SECURITY.md). +Authentication: verified ChittyAuth bearer JWT for end-user/agent callers; legacy internal service-token callers remain supported with explicit `X-Chitty-User-Id`. Tenant authorization still comes from `tenant_users`. After authorization, every protected request receives a channel-neutral execution context carrying actor, tenant scope, capability/intent, source provenance, and trace metadata. Source metadata never grants financial authority. See [SECURITY.md](SECURITY.md). > ⚠️ **Phase 2 remaining**: ChittyConnect MCP integration not yet wired. Internal MCP routes work today; cross-service MCP discovery via ChittyConnect is pending. @@ -83,6 +83,10 @@ When working in this repo, prefer these subagents (see user's `~/.claude/agents/ - COA modifications (L4) — only `tenant_users.role` ∈ {owner, admin} - Webhook signature verification — never bypassed for any agent caller +### Channel-Neutral Execution Context + +Protected requests are normalized after authentication + tenant membership checks. The runtime context is request-scoped only (no new database/schema) and contains the verified actor, authorized tenant scope, inferred or route-specific capability/intent, source provenance, and trace metadata. `X-Source-Service` plus W3C `traceparent`/`baggage` carry portable provenance for ChatGPT, Claude, ChittyClaw/OpenClaw, and other adapters. Platform workspace/channel metadata and inferred intent are audit context only and MUST NOT affect `tenant_users` authorization or mutation authority. + **ChittyFinance does delegate these to external agents:** - Identity (ChittyID via OAuth 2.0 PKCE) - Token validation (ChittyAuth) diff --git a/CHARTER.md b/CHARTER.md index 5796ef2..a28611b 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -42,6 +42,7 @@ ChittyFinance is a **full-stack financial management platform** for the ChittyOS - Forensic accounting (Benford's Law, duplicate detection, flow of funds, damages calculation) - GitHub integration for project cost attribution - Tenant-scoped financial data isolation +- Channel-neutral request execution context for ChatGPT, Claude, ChittyClaw/OpenClaw, and other adapters (actor + authorized scope + capability/intent + provenance + trace; request-scoped only) - Inbound email handling at `finance@chitty.cc` (Cloudflare Email Service) ### IS NOT Responsible For @@ -114,7 +115,7 @@ IT CAN BE LLC (holding) ### MCP | Endpoint | Method | Purpose | |----------|--------|---------| -| `/mcp` | POST | JSON-RPC MCP resources/tools; caller identity via ChittyAuth bearer JWT or legacy service-token compatibility lane; tenant authorization remains membership-scoped | +| `/mcp` | POST | JSON-RPC MCP resources/tools; caller identity via ChittyAuth bearer JWT or legacy service-token compatibility lane; tenant authorization remains membership-scoped; MCP methods label canonical capability + read/suggest/execute intent | ### Financial Data | Endpoint | Method | Purpose | diff --git a/CHITTY.md b/CHITTY.md index 11cde60..3c1aa2f 100644 --- a/CHITTY.md +++ b/CHITTY.md @@ -20,7 +20,7 @@ Full-stack financial management platform providing intelligent tracking, AI-powe ## Architecture -Dual-mode: Hono on Cloudflare Workers (production) with Neon PostgreSQL multi-tenant, or Hono via `@hono/node-server` with SQLite (local dev). React frontend with Vite. +Dual-mode: Hono on Cloudflare Workers (production) with Neon PostgreSQL multi-tenant, or Hono via `@hono/node-server` with SQLite (local dev). React frontend with Vite. Protected requests are normalized into a request-scoped execution context after identity and tenant authorization so ChatGPT, Claude, ChittyClaw/OpenClaw, and web clients can share the same financial capability surface without sharing platform-specific authority logic. ### Stack - **Runtime**: Cloudflare Workers + Hono (production) / Hono node-server (dev) / Express (legacy `dev:legacy` fallback) diff --git a/SECURITY.md b/SECURITY.md index ab1f54c..6f1ac9b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,6 +47,7 @@ We follow coordinated disclosure and will credit reporters unless anonymity is p - MCP `finance://tenants` enumerates only the authenticated caller's memberships - All storage methods enforce `tenantId` filtering - Inter-tenant data access prevented at the storage abstraction layer +- Channel/workspace/session provenance is resolved only after actor + tenant authorization and cannot select or elevate tenant access ### Secret Management @@ -56,6 +57,13 @@ We follow coordinated disclosure and will credit reporters unless anonymity is p - No secrets in code, KV, or R2 - Pre-commit hooks scan for credential patterns +### Cross-Channel Provenance + +- Existing `X-Source-Service` is recorded as a caller-claimed adapter identity for audit correlation; it is not an attestation +- W3C `traceparent` carries distributed trace linkage; W3C `baggage` may carry `chitty.source`, `chitty.channel`, `chitty.workspace`, and `chitty.session` +- Provenance fields and inferred `intent` are non-authoritative audit context: they are never used to derive `userId`, tenant membership, financial role, or write permission +- ChittyLedger audit writes inherit the request execution context automatically + ### OAuth & Webhook Security - **OAuth state**: HMAC-SHA256 signed tokens, 10-minute expiry, timing-safe verification diff --git a/server/__tests__/execution-context.test.ts b/server/__tests__/execution-context.test.ts new file mode 100644 index 0000000..c61358e --- /dev/null +++ b/server/__tests__/execution-context.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; +import { + executionContextMiddleware, + inferExecutionIntent, + setExecutionOperation, +} from '../middleware/execution-context'; + +function buildApp() { + const app = new Hono(); + + app.use('*', async (c, next) => { + c.set('userId', 'user-1'); + c.set('authMethod', 'chittyauth'); + c.set('tenantId', 'tenant-authorized'); + await next(); + }); + app.use('*', executionContextMiddleware); + + app.get('/api/test', (c) => c.json(c.get('executionContext'))); + app.post('/api/allocations/preview', (c) => c.json(c.get('executionContext'))); + app.post('/api/allocations/execute', (c) => c.json(c.get('executionContext'))); + app.post('/mcp-test', (c) => { + setExecutionOperation(c, 'finance.mcp.tool:get-property-advice', 'suggest'); + return c.json(c.get('executionContext')); + }); + + return app; +} + +describe('execution context', () => { + it('infers read, preview, suggest, and execute mechanically', () => { + expect(inferExecutionIntent('GET', '/api/accounts')).toBe('read'); + expect(inferExecutionIntent('POST', '/api/allocations/preview')).toBe('preview'); + expect(inferExecutionIntent('POST', '/api/classification/suggest')).toBe('suggest'); + expect(inferExecutionIntent('POST', '/api/allocations/execute')).toBe('execute'); + expect(inferExecutionIntent('POST', '/api/x/preview-and-commit')).toBe('execute'); + }); + + it('captures sanitized channel-neutral provenance from source header and W3C baggage', async () => { + const app = buildApp(); + const res = await app.request('/api/test', { + headers: { + 'X-Source-Service': 'chittyclaw', + baggage: 'chitty.channel=slack;prop=1,chitty.workspace=workspace-1,chitty.session=session-1', + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }, + }); + const body = await res.json() as any; + + expect(body.actor).toEqual({ userId: 'user-1', authMethod: 'chittyauth' }); + expect(body.source).toEqual({ + service: 'chittyclaw', + claimed: true, + channel: 'slack', + workspace: 'workspace-1', + session: 'session-1', + }); + expect(body.scope).toEqual({ tenantId: 'tenant-authorized' }); + expect(body.intent).toBe('read'); + expect(body.trace.traceparent).toContain('4bf92f3577b34da6a3ce929d0e0e4736'); + expect(body.trace.requestId).toBeTruthy(); + }); + + it('drops malformed or oversized provenance values', async () => { + const app = buildApp(); + const res = await app.request('/api/test', { + headers: { + 'X-Source-Service': 'x'.repeat(129), + baggage: 'chitty.channel=' + 'y'.repeat(9000), + traceparent: 'not-a-traceparent', + }, + }); + const body = await res.json() as any; + + expect(body.source.service).toBe('finance.chitty.cc'); + expect(body.source.channel).toBeUndefined(); + expect(body.trace.traceparent).toBeUndefined(); + }); + + it('uses chitty.source baggage only as a claimed provenance fallback', async () => { + const app = buildApp(); + const body = await (await app.request('/api/test', { + headers: { baggage: 'chitty.source=claude' }, + })).json() as any; + + expect(body.source.service).toBe('claude'); + expect(body.source.claimed).toBe(true); + }); + + it('does not derive financial scope from source metadata', async () => { + const app = buildApp(); + const res = await app.request('/api/test', { + headers: { + baggage: 'chitty.workspace=tenant-attacker,chitty.session=tenant-other', + }, + }); + const body = await res.json() as any; + + expect(body.scope.tenantId).toBe('tenant-authorized'); + expect(body.source.workspace).toBe('tenant-attacker'); + }); + + it('marks preview and execute routes distinctly', async () => { + const app = buildApp(); + + const preview = await (await app.request('/api/allocations/preview', { method: 'POST' })).json() as any; + const execute = await (await app.request('/api/allocations/execute', { method: 'POST' })).json() as any; + + expect(preview.intent).toBe('preview'); + expect(execute.intent).toBe('execute'); + }); + + it('allows MCP to override the generic HTTP operation with canonical capability intent', async () => { + const app = buildApp(); + const body = await (await app.request('/mcp-test', { method: 'POST' })).json() as any; + + expect(body.capability).toBe('finance.mcp.tool:get-property-advice'); + expect(body.intent).toBe('suggest'); + expect(body.actor.userId).toBe('user-1'); + expect(body.scope.tenantId).toBe('tenant-authorized'); + }); +}); diff --git a/server/__tests__/ledger-client.test.ts b/server/__tests__/ledger-client.test.ts index 1d02b42..1d31437 100644 --- a/server/__tests__/ledger-client.test.ts +++ b/server/__tests__/ledger-client.test.ts @@ -2,7 +2,7 @@ * Ledger client tests — mocked fetch, no real network calls */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { postLedgerEntry, resolveLedgerBase, logToLedger } from '../lib/ledger-client'; +import { postLedgerEntry, resolveLedgerBase, logToLedger, ledgerLog } from '../lib/ledger-client'; const MOCK_RESPONSE = { id: 'uuid-1', sequenceNumber: '42', hash: 'abc123def456' }; @@ -107,4 +107,38 @@ describe('ledger-client', () => { )).resolves.toBeUndefined(); }); }); + + describe('ledgerLog', () => { + it('attaches execution provenance while preserving existing metadata', async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify(MOCK_RESPONSE), { status: 200 })); + + let pending: Promise | undefined; + const execution = { + actor: { userId: 'user-1', authMethod: 'chittyauth' as const }, + source: { service: 'chittyclaw', claimed: true, channel: 'slack' }, + scope: { tenantId: 'tenant-1' }, + capability: 'finance.allocations.execute', + intent: 'execute' as const, + trace: { requestId: 'req-1' }, + }; + + ledgerLog({ + executionCtx: { waitUntil: (promise) => { pending = promise; } }, + get: () => execution, + }, { + entityType: 'audit', + action: 'allocation.executed', + metadata: { period: '2026-09' }, + }, { + CHITTY_LEDGER_BASE: 'https://ledger.chitty.cc', + CHITTY_AUTH_SERVICE_TOKEN: 'tok-123', + }); + + await pending; + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.metadata.period).toBe('2026-09'); + expect(body.metadata.execution).toEqual(execution); + }); + }); }); diff --git a/server/__tests__/mcp.test.ts b/server/__tests__/mcp.test.ts index 0252a58..c862c68 100644 --- a/server/__tests__/mcp.test.ts +++ b/server/__tests__/mcp.test.ts @@ -36,17 +36,28 @@ function createMockStorage() { function buildApp() { const app = new Hono(); const storage = createMockStorage(); + let observedExecution: any; // Inject mock storage + authorized caller scope into context app.use('*', async (c, next) => { c.set('storage', storage as any); c.set('tenantId', 'test-tenant'); c.set('userId', 'user-1'); + c.set('authMethod', 'chittyauth'); + c.set('executionContext', { + actor: { userId: 'user-1', authMethod: 'chittyauth' }, + source: { service: 'test', claimed: false }, + scope: { tenantId: 'test-tenant' }, + capability: 'finance.http.post:/mcp', + intent: 'execute', + trace: { requestId: 'test-request' }, + }); await next(); + observedExecution = c.get('executionContext'); }); app.route('/', mcpRoutes); - return { app, storage }; + return { app, storage, getExecution: () => observedExecution }; } function rpc(app: Hono, method: string, params?: Record, id: number | string = 1) { @@ -60,9 +71,10 @@ function rpc(app: Hono, method: string, params?: Record, i describe('MCP endpoint', () => { let app: Hono; let storage: ReturnType; + let getExecution: () => any; beforeEach(() => { - ({ app, storage } = buildApp()); + ({ app, storage, getExecution } = buildApp()); }); // ── Protocol ── @@ -76,6 +88,8 @@ describe('MCP endpoint', () => { expect(body.result.serverInfo.name).toBe('chittyfinance'); expect(body.result.capabilities.resources).toBeDefined(); expect(body.result.capabilities.tools).toBeDefined(); + expect(getExecution().capability).toBe('finance.mcp.initialize'); + expect(getExecution().intent).toBe('read'); }); it('rejects bad JSON', async () => { @@ -128,6 +142,8 @@ describe('MCP endpoint', () => { expect(data.totalProperties).toBe(2); expect(data.totalValue).toBe(600000); expect(data.totalNOI).toBe(30000); // 15000 * 2 + expect(getExecution().capability).toBe('finance.mcp.resources.read:portfolio-summary'); + expect(getExecution().intent).toBe('read'); }); it('reads finance://properties', async () => { @@ -197,6 +213,8 @@ describe('MCP endpoint', () => { expect(body.result.content).toHaveLength(1); expect(body.result.content[0].text).toContain('City Studio'); expect(body.result.content[0].text).toContain('Rule-based advice'); + expect(getExecution().capability).toBe('finance.mcp.tool:get-property-advice'); + expect(getExecution().intent).toBe('suggest'); }); it('calls refresh-valuation', async () => { @@ -207,6 +225,8 @@ describe('MCP endpoint', () => { const body = await res.json() as any; expect(body.result.content[0].text).toContain('Valuation refresh queued'); expect(body.result.content[0].text).toContain('City Studio'); + expect(getExecution().capability).toBe('finance.mcp.tool:refresh-valuation'); + expect(getExecution().intent).toBe('execute'); }); it('returns not-found for missing property in tool call', async () => { @@ -219,7 +239,7 @@ describe('MCP endpoint', () => { expect(body.result.content[0].text).toContain('not found'); }); - it('returns error for unknown tool', async () => { + it('preserves the existing unknown-tool error path', async () => { const res = await rpc(app, 'tools/call', { name: 'nonexistent-tool', arguments: {}, @@ -227,6 +247,8 @@ describe('MCP endpoint', () => { expect(res.status).toBe(500); const body = await res.json() as any; expect(body.error.code).toBe(-32000); + expect(getExecution().capability).toBe('finance.mcp.tools.call'); + expect(getExecution().intent).toBe('execute'); }); it('returns error for missing tool name', async () => { diff --git a/server/app.ts b/server/app.ts index 9aa392a..0cdfd32 100644 --- a/server/app.ts +++ b/server/app.ts @@ -8,6 +8,7 @@ import { hybridAuth, serviceAuth } from './middleware/auth'; import { sessionRoutes } from './routes/session'; import { callerContext } from './middleware/caller'; import { tenantMiddleware } from './middleware/tenant'; +import { executionContextMiddleware } from './middleware/execution-context'; import { healthRoutes } from './routes/health'; import { docRoutes } from './routes/docs'; import { accountRoutes } from './accounting/accounts'; @@ -73,7 +74,7 @@ export function createApp(deps: AppDeps = {}) { // storageMiddleware runs first so hybridAuth can resolve JWT → chittyId → userId const authAndContext: MiddlewareHandler[] = [storageMiddleware, hybridAuth, callerContext]; - const protectedRoute: MiddlewareHandler[] = [...authAndContext, tenantMiddleware]; + const protectedRoute: MiddlewareHandler[] = [...authAndContext, tenantMiddleware, executionContextMiddleware]; const app = new Hono(); @@ -84,7 +85,7 @@ export function createApp(deps: AppDeps = {}) { app.use('*', cors({ origin: ['https://app.command.chitty.cc', 'https://command.chitty.cc', 'https://finance.chitty.cc', 'http://localhost:5000', 'http://localhost:3000'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Source-Service', 'X-Account-ID', 'Stripe-Signature'], + allowHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Source-Service', 'X-Account-ID', 'Stripe-Signature', 'traceparent', 'baggage'], credentials: true, })); diff --git a/server/env.ts b/server/env.ts index f9e6aa8..57a714a 100644 --- a/server/env.ts +++ b/server/env.ts @@ -54,11 +54,13 @@ export interface Env { import type { SystemStorage } from './storage/system'; import type { Database } from './db/connection'; +import type { FinanceExecutionContext } from './middleware/execution-context'; export interface Variables { tenantId: string; userId: string; authMethod: 'service' | 'chittyauth' | 'session'; + executionContext: FinanceExecutionContext; storage: SystemStorage; /** * The same drizzle handle SystemStorage was built on. Routes go through storage diff --git a/server/lib/ledger-client.ts b/server/lib/ledger-client.ts index b41be9c..c7b354d 100644 --- a/server/lib/ledger-client.ts +++ b/server/lib/ledger-client.ts @@ -1,3 +1,5 @@ +import type { FinanceExecutionContext } from '../middleware/execution-context'; + /** * ChittyLedger client for immutable audit trail entries. * 100% Cloudflare Workers compatible — no Node.js APIs, no process.env. @@ -110,8 +112,19 @@ export async function logToLedger(entry: LedgerEntry, env: LedgerEnv): Promise): void } }, entry: LedgerEntry, env: LedgerEnv): void { - const promise = logToLedger(entry, env); +export function ledgerLog( + c: { + executionCtx: { waitUntil(p: Promise): void }; + get?: (name: 'executionContext') => FinanceExecutionContext | undefined; + }, + entry: LedgerEntry, + env: LedgerEnv, +): void { + const execution = typeof c.get === 'function' ? c.get('executionContext') : undefined; + const enrichedEntry = execution + ? { ...entry, metadata: { ...(entry.metadata ?? {}), execution } } + : entry; + const promise = logToLedger(enrichedEntry, env); try { c.executionCtx.waitUntil(promise); } catch { diff --git a/server/middleware/execution-context.ts b/server/middleware/execution-context.ts new file mode 100644 index 0000000..7c66707 --- /dev/null +++ b/server/middleware/execution-context.ts @@ -0,0 +1,155 @@ +import type { Context, MiddlewareHandler } from 'hono'; +import type { HonoEnv } from '../env'; + +export type ExecutionIntent = 'read' | 'suggest' | 'preview' | 'execute'; + +export interface FinanceExecutionContext { + actor: { + userId: string; + authMethod: 'service' | 'chittyauth' | 'session'; + }; + source: { + service: string; + claimed: boolean; + channel?: string; + workspace?: string; + session?: string; + }; + scope: { + tenantId: string; + }; + capability: string; + intent: ExecutionIntent; + trace: { + requestId: string; + traceparent?: string; + }; +} + +const MAX_BAGGAGE_BYTES = 8192; +const MAX_PROVENANCE_VALUE = 128; +const TRACEPARENT_RE = /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/; +const PRINTABLE_RE = /^[\x20-\x7E]+$/; + +function sanitizeValue(value?: string | null): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_PROVENANCE_VALUE || !PRINTABLE_RE.test(trimmed)) { + return undefined; + } + return trimmed; +} + +function sanitizeTraceparent(value?: string | null): string | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + return TRACEPARENT_RE.test(normalized) ? normalized : undefined; +} + +function parseBaggage(raw?: string): Map { + const result = new Map(); + if (!raw || new TextEncoder().encode(raw).byteLength > MAX_BAGGAGE_BYTES) return result; + + for (const member of raw.split(',')) { + const pair = member.trim().split(';', 1)[0]; + const separator = pair.indexOf('='); + if (separator <= 0) continue; + + const key = pair.slice(0, separator).trim(); + const encodedValue = pair.slice(separator + 1).trim(); + if (!key || !encodedValue) continue; + + let decoded = encodedValue; + try { + decoded = decodeURIComponent(encodedValue); + } catch { + continue; + } + + const safe = sanitizeValue(decoded); + if (safe) result.set(key, safe); + } + + return result; +} + +function normalizePath(pathname: string): string { + return pathname + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi, ':id') + .replace(/\/\d+(?=\/|$)/g, '/:id'); +} + +function terminalPathSegment(pathname: string): string | undefined { + return pathname.split('/').filter(Boolean).at(-1); +} + +export function inferExecutionIntent(method: string, pathname: string): ExecutionIntent { + const upper = method.toUpperCase(); + if (upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS') return 'read'; + + const terminal = terminalPathSegment(pathname); + if (terminal === 'preview') return 'preview'; + if (terminal === 'suggest' || terminal === 'advice') return 'suggest'; + + // Fail safe: mutating HTTP methods are execute unless the route explicitly + // terminates in a known non-authoritative operation label. + return 'execute'; +} + +function defaultCapability(method: string, pathname: string): string { + return `finance.http.${method.toLowerCase()}:${normalizePath(pathname)}`; +} + +/** + * Build channel-neutral execution provenance after actor + tenant authorization. + * + * Source/channel/workspace/session values are caller claims used for provenance only. + * Authorization never depends on them. + */ +export const executionContextMiddleware: MiddlewareHandler = async (c, next) => { + const baggage = parseBaggage(c.req.header('baggage')); + const headerSource = sanitizeValue(c.req.header('x-source-service')); + const baggageSource = baggage.get('chitty.source'); + const sourceService = headerSource ?? baggageSource ?? 'finance.chitty.cc'; + const pathname = c.req.path; + const traceparent = sanitizeTraceparent(c.req.header('traceparent')); + + const context: FinanceExecutionContext = { + actor: { + userId: c.get('userId'), + authMethod: c.get('authMethod'), + }, + source: { + service: sourceService, + claimed: Boolean(headerSource ?? baggageSource), + channel: baggage.get('chitty.channel') || undefined, + workspace: baggage.get('chitty.workspace') || undefined, + session: baggage.get('chitty.session') || undefined, + }, + scope: { + tenantId: c.get('tenantId'), + }, + capability: defaultCapability(c.req.method, pathname), + intent: inferExecutionIntent(c.req.method, pathname), + trace: { + requestId: traceparent?.split('-')[1] ?? crypto.randomUUID(), + traceparent, + }, + }; + + c.set('executionContext', context); + await next(); +}; + +export function setExecutionOperation( + c: Context, + capability: string, + intent: ExecutionIntent, +): FinanceExecutionContext { + const current = c.get('executionContext'); + if (!current) throw new Error('execution_context_unavailable'); + + const updated = { ...current, capability: sanitizeValue(capability) ?? 'finance.unknown', intent }; + c.set('executionContext', updated); + return updated; +} diff --git a/server/routes/mcp.ts b/server/routes/mcp.ts index a4d6be8..3414d32 100644 --- a/server/routes/mcp.ts +++ b/server/routes/mcp.ts @@ -16,6 +16,7 @@ import { Hono } from 'hono'; import type { HonoEnv } from '../env'; +import { setExecutionOperation, type ExecutionIntent } from '../middleware/execution-context'; export const mcpRoutes = new Hono(); @@ -48,6 +49,12 @@ const CAPABILITIES = { tools: {}, }; +const RESOURCE_CAPABILITIES: Record = { + 'finance://portfolio/summary': 'finance.mcp.resources.read:portfolio-summary', + 'finance://properties': 'finance.mcp.resources.read:properties', + 'finance://tenants': 'finance.mcp.resources.read:tenants', +}; + const RESOURCES = [ { uri: 'finance://portfolio/summary', @@ -69,10 +76,18 @@ const RESOURCES = [ }, ]; -const TOOLS = [ +interface McpToolDefinition { + name: string; + description: string; + intent: ExecutionIntent; + inputSchema: Record; +} + +const TOOL_DEFINITIONS: McpToolDefinition[] = [ { name: 'get-property-advice', description: 'Get AI-powered financial advice for a specific property.', + intent: 'suggest', inputSchema: { type: 'object' as const, properties: { @@ -85,6 +100,7 @@ const TOOLS = [ { name: 'refresh-valuation', description: 'Refresh property valuation estimates from external providers (Zillow, Redfin, HouseCanary, ATTOM, County).', + intent: 'execute', inputSchema: { type: 'object' as const, properties: { @@ -95,6 +111,8 @@ const TOOLS = [ }, ]; +const TOOLS = TOOL_DEFINITIONS.map(({ intent: _intent, ...tool }) => tool); + // ── Resource Handlers ── async function readResource(uri: string, storage: any, tenantId: string, userId: string): Promise<{ contents: any[] }> { @@ -263,6 +281,7 @@ mcpRoutes.post('/mcp', async (c) => { try { switch (body.method) { case 'initialize': + setExecutionOperation(c, 'finance.mcp.initialize', 'read'); return c.json(rpcOk(body.id, { protocolVersion: '2024-11-05', serverInfo: SERVER_INFO, @@ -270,22 +289,31 @@ mcpRoutes.post('/mcp', async (c) => { })); case 'resources/list': + setExecutionOperation(c, 'finance.mcp.resources.list', 'read'); return c.json(rpcOk(body.id, { resources: RESOURCES })); case 'resources/read': { const uri = body.params?.uri; if (!uri) return c.json(rpcError(body.id, -32602, 'Missing uri param'), 400); + setExecutionOperation(c, RESOURCE_CAPABILITIES[uri] ?? 'finance.mcp.resources.read:unknown', 'read'); const result = await readResource(uri, storage, tenantId, userId); return c.json(rpcOk(body.id, result)); } case 'tools/list': + setExecutionOperation(c, 'finance.mcp.tools.list', 'read'); return c.json(rpcOk(body.id, { tools: TOOLS })); case 'tools/call': { const toolName = body.params?.name; const toolArgs = body.params?.arguments || {}; if (!toolName) return c.json(rpcError(body.id, -32602, 'Missing tool name'), 400); + const toolDefinition = TOOL_DEFINITIONS.find((tool) => tool.name === toolName); + setExecutionOperation( + c, + toolDefinition ? `finance.mcp.tool:${toolName}` : 'finance.mcp.tools.call', + toolDefinition?.intent ?? 'execute', + ); const result = await callTool(toolName, toolArgs, storage, tenantId, c.env); return c.json(rpcOk(body.id, result)); }