diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 01ed8929e..d7b5c45d1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -77,7 +77,7 @@ jobs: - name: Build the verified native TreeDX engine working-directory: .treeseed/tools/treedx/apps/api run: MIX_ENV=dev mix compile - - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@69c07896708deaaed228cc6e14f12c41e5289981 + - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@ee3d869cb38a5e74485da58553db4057e5cdcdd6 env: NODE_ENV: production with: diff --git a/package-lock.json b/package-lock.json index 33d61b77b..54fe4bd79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@react-email/render": "^2.0.8", "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#69c07896708deaaed228cc6e14f12c41e5289981", + "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#ee3d869cb38a5e74485da58553db4057e5cdcdd6", "@treeseed/treedx": "0.3.0-rc.18", "drizzle-orm": "^0.45.2", "hono": "4.13.3", @@ -167,8 +167,7 @@ "version": "0.5.8", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz", "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==", - "devOptional": true, - "peer": true + "devOptional": true }, "node_modules/@emnapi/runtime": { "version": "1.11.3", @@ -1151,7 +1150,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", "license": "MIT", - "peer": true, "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" @@ -1286,7 +1284,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.7.tgz", "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.4", @@ -1934,9 +1931,9 @@ } }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.123", - "resolved": "git+ssh://git@github.com/treeseed-ai/sdk.git#69c07896708deaaed228cc6e14f12c41e5289981", - "integrity": "sha512-EE1BZxRRqI/JT/OfW50D/fs7iSSKIYGNXP4zJ+SwbhbgTXCb+m+xt3IJWraY8qTmQiIE2WEx8erhJFQQUhscWQ==", + "version": "0.13.0-rc.124", + "resolved": "git+ssh://git@github.com/treeseed-ai/sdk.git#ee3d869cb38a5e74485da58553db4057e5cdcdd6", + "integrity": "sha512-5+k0pwviF2b8RCyJD2d2X2+XiATrHrcAvveow9CIYhMwlBVx9opEdxmyvHfSy2EERYedD/8C1qz1BjC6tdx9CA==", "license": "Apache-2.0", "dependencies": { "@treeseed/treedx": "0.3.0-rc.17", @@ -2071,7 +2068,6 @@ "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2221,7 +2217,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2275,7 +2270,6 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -2908,7 +2902,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4296,7 +4289,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -4571,7 +4563,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/selderee": { "version": "0.11.0", @@ -4837,7 +4830,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4886,7 +4878,6 @@ "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -4932,7 +4923,6 @@ "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -5070,7 +5060,6 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -5297,7 +5286,6 @@ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260831.1.tgz", "integrity": "sha512-A2LwrkBel/FnKABPfeBAMiL6v70+rugnunqQRfWsWZjlhsTZoBScWUVunMy/xLCGLjWCQL2zp39AVR6aO0jurQ==", "hasInstallScript": true, - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -5424,7 +5412,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f08259ded..3d181948d 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@react-email/render": "^2.0.8", "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#69c07896708deaaed228cc6e14f12c41e5289981", + "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#ee3d869cb38a5e74485da58553db4057e5cdcdd6", "@treeseed/treedx": "0.3.0-rc.18", "drizzle-orm": "^0.45.2", "hono": "4.13.3", diff --git a/src/api/auth/browser/api-routes.ts b/src/api/auth/browser/api-routes.ts index 64ad1aa83..75e4caee3 100644 --- a/src/api/auth/browser/api-routes.ts +++ b/src/api/auth/browser/api-routes.ts @@ -3,6 +3,7 @@ import { protectedResourceMetadataSchema } from '@treeseed/sdk/identity'; import type { createApiIdentityRuntime } from './runtime.ts'; import { installIdentityBrowserRoutes } from './routes.ts'; import { installControlPlaneResourceRoutes } from '../../control-plane/http/resource-routes.ts'; +import { controlPlaneOperations } from '../../control-plane/catalog/index.ts'; /** One resource server plus the workload-authenticated BFF bridge. It cannot * issue passwords, API tokens or OAuth grants. The approved issuer does that. @@ -13,10 +14,14 @@ export function installApiIdentityRoutes(app: Hono, runtime: A mcpBusForPrincipal?: Parameters[4]; }) { const metadata = protectedResourceMetadataSchema.parse(runtime.metadata); - for (const path of ['/.well-known/oauth-protected-resource', '/.well-known/oauth-protected-resource/mcp']) { - app.get(path, c => c.json(metadata, 200, { 'cache-control': 'public, max-age=60' })); - } + const registry = options.registry ?? controlPlaneOperations; + const mcpScopes = new Set(registry.catalog.operations + .filter(operation => operation.surfaces.includes('mcp_tool') || operation.surfaces.includes('mcp_resource')) + .flatMap(operation => operation.oauthScopes)); + const mcpMetadata = { ...metadata, scopes_supported: (metadata.scopes_supported ?? []).filter(scope => mcpScopes.has(scope)) }; + app.get('/.well-known/oauth-protected-resource', c => c.json(metadata, 200, { 'cache-control': 'public, max-age=60' })); + app.get('/.well-known/oauth-protected-resource/mcp', c => c.json(mcpMetadata, 200, { 'cache-control': 'public, max-age=60' })); installIdentityBrowserRoutes(app, runtime); - return installControlPlaneResourceRoutes(app, runtime.authenticate, options.registry, options.confirmations, + return installControlPlaneResourceRoutes(app, runtime.authenticate, registry, options.confirmations, options.mcpBusForPrincipal, metadata.resource); } diff --git a/src/api/control-plane/catalog/sdk-standard-schema.ts b/src/api/control-plane/catalog/sdk-standard-schema.ts index 177b64846..14bf68e38 100644 --- a/src/api/control-plane/catalog/sdk-standard-schema.ts +++ b/src/api/control-plane/catalog/sdk-standard-schema.ts @@ -1,5 +1,6 @@ import { controlPlaneSchemaJson, type ControlPlaneOperationBinding } from '@treeseed/sdk/operator-contracts'; import { z } from 'zod'; +import { CONFIRMATION_HEADER } from '../confirmation/confirmation-service.ts'; type SdkSchema = ControlPlaneOperationBinding['schema']['output']; @@ -32,22 +33,26 @@ export function sdkStandardSchema(schema: SdkSchema) { } export function sdkOperationInputStandardSchema(binding: { - descriptor: Pick; + descriptor: Pick; schema: { path: SdkSchema; query: SdkSchema; body: SdkSchema }; }) { + const acceptsBody = Boolean(binding.descriptor.rest && binding.descriptor.rest.method !== 'GET'); const headerNames: string[] = binding.descriptor.kind === 'mutation' ? [binding.descriptor.idempotency.header, ...(binding.descriptor.concurrency.required ? [binding.descriptor.concurrency.writeHeader] : [])] : []; const headersSchema = headerNames.length ? z.object(Object.fromEntries(headerNames.map(name => [name, z.string().min(1).regex(/^(?=.*\S)[^\r\n\u0000]+$/u).describe(name === binding.descriptor.idempotency.header ? 'Reuse this key for retries of the unchanged request.' : 'Exact current resource version read before this mutation.'), - ]))).strict() : undefined; + ]))).extend(binding.descriptor.confirmation === 'input_required' ? { + [CONFIRMATION_HEADER]: z.string().min(1).regex(/^(?=.*\S)[^\r\n\u0000]+$/u) + .describe('Encoded signed confirmation returned by a previous call with these exact arguments; supply only when approval is authorized.').optional(), + } : {}).strict() : undefined; const document = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', properties: { path: mcpSchemaJson(binding.schema.path), query: mcpSchemaJson(binding.schema.query), - ...(binding.descriptor.kind === 'mutation' ? { body: mcpSchemaJson(binding.schema.body) } : {}), + ...(acceptsBody ? { body: mcpSchemaJson(binding.schema.body) } : {}), ...(headersSchema ? { headers: z.toJSONSchema(headersSchema, { target: 'draft-7' }) } : {}), }, additionalProperties: false, @@ -69,7 +74,7 @@ export function sdkOperationInputStandardSchema(binding: { } const path = binding.schema.path.safeParse(input.path ?? {}); const query = binding.schema.query.safeParse(input.query ?? {}); - const body = binding.schema.body.safeParse(binding.descriptor.kind === 'mutation' ? input.body ?? {} : undefined); + const body = binding.schema.body.safeParse(acceptsBody ? input.body ?? {} : undefined); if (!path.success) return { issues: issues(path.error) }; if (!query.success) return { issues: issues(query.error) }; if (!body.success) return { issues: issues(body.error) }; diff --git a/src/api/control-plane/confirmation/confirmation-service.ts b/src/api/control-plane/confirmation/confirmation-service.ts index cc030c147..dbc5ce840 100644 --- a/src/api/control-plane/confirmation/confirmation-service.ts +++ b/src/api/control-plane/confirmation/confirmation-service.ts @@ -1,6 +1,8 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import type { ConfirmationState, InputRequired } from '@treeseed/sdk/operator-contracts'; +export const CONFIRMATION_HEADER = 'x-treeseed-confirmation'; + interface ConfirmationClaims { schemaVersion: 'treeseed.confirmation-state/v1'; principalId: string; diff --git a/src/api/control-plane/http/operation-http-handler.ts b/src/api/control-plane/http/operation-http-handler.ts index d9a091a76..231fc2aff 100644 --- a/src/api/control-plane/http/operation-http-handler.ts +++ b/src/api/control-plane/http/operation-http-handler.ts @@ -4,7 +4,7 @@ import type { Context } from 'hono'; import { ZodError } from 'zod'; import { ControlPlaneOperationError, type BoundOperation } from '../catalog/operation-registry.ts'; import { enforceOperationAuthorization, readOperationPreconditions } from '../authorization/invocation-policy.ts'; -import { decodeConfirmation, type ConfirmationService } from '../confirmation/confirmation-service.ts'; +import { CONFIRMATION_HEADER, decodeConfirmation, type ConfirmationService } from '../confirmation/confirmation-service.ts'; type Authenticate = (request: Request) => Promise; @@ -90,7 +90,7 @@ export function createOperationHttpHandler( } const identity = { principalId: String((authInfo.extra.principal as any).id), clientId: authInfo.clientId, operationId: descriptor.operationId, arguments: input }; - const supplied = decodeConfirmation(context.req.header('x-treeseed-confirmation')); + const supplied = decodeConfirmation(context.req.header(CONFIRMATION_HEADER)); if (!supplied) { const required = confirmations.request({ ...identity, requestId }); return context.json({ type: 'https://treeseed.dev/problems/confirmation_required', title: 'Confirmation required', diff --git a/src/api/control-plane/mcp/create-mcp-handler.ts b/src/api/control-plane/mcp/create-mcp-handler.ts index f3bcb1ba8..13b3d3185 100644 --- a/src/api/control-plane/mcp/create-mcp-handler.ts +++ b/src/api/control-plane/mcp/create-mcp-handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import type { OperationRegistry } from '../catalog/operation-registry.ts'; import { sdkOperationInputStandardSchema, sdkStandardSchema } from '../catalog/sdk-standard-schema.ts'; import { createMcpCatalog } from './mcp-catalog.ts'; -import { decodeConfirmation, encodeConfirmation, type ConfirmationService } from '../confirmation/confirmation-service.ts'; +import { CONFIRMATION_HEADER, decodeConfirmation, encodeConfirmation, type ConfirmationService } from '../confirmation/confirmation-service.ts'; import { completePromptObjective, completeResourceVariable } from './completion-source.ts'; import { enforceOperationAuthorization, readOperationPreconditions } from '../authorization/invocation-policy.ts'; @@ -34,11 +34,15 @@ export function createControlPlaneMcpHandler(registry: OperationRegistry, confir const principalId = String((requestContext.authInfo?.extra?.principal as any)?.id ?? ''); const clientId = String(requestContext.authInfo?.clientId ?? ''); if (!confirmations || !principalId || !clientId) return { content: [{ type: 'text', text: 'Confirmation is unavailable.' }], isError: true }; - const identity = { principalId, clientId, operationId: descriptor.operationId, arguments: input }; - const requestState = context.mcpReq.requestState(); + const { [CONFIRMATION_HEADER]: supplied, ...operationHeaders } = headers ?? {}; + const identity = { principalId, clientId, operationId: descriptor.operationId, + arguments: headers ? { ...operationInput, headers: operationHeaders } : operationInput }; + const requestState = supplied ?? context.mcpReq.requestState(); const accepted = acceptedContent<{ confirm: boolean }>(context.mcpReq.inputResponses, 'confirm'); - if (!requestState || accepted?.confirm !== true) { + if (!requestState || (supplied === undefined && accepted?.confirm !== true)) { const required = confirmations.request({ ...identity, requestId: String(context.mcpReq.id) }); + if (requestContext.era === 'legacy') return { isError: true, + content: [{ type: 'text', text: JSON.stringify(required) }] }; return inputRequired({ requestState: encodeConfirmation(required.confirmation), inputRequests: { confirm: inputRequired.elicit({ message: required.prompt, requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'], additionalProperties: false, @@ -99,5 +103,5 @@ export function createControlPlaneMcpHandler(registry: OperationRegistry, confir })); } return server; - }, { legacy: 'reject', responseMode: 'auto', bus }); + }, { legacy: 'stateless', responseMode: 'auto', bus }); } diff --git a/tests/unit/control-plane/accounts/identity/resource-http.test.ts b/tests/unit/control-plane/accounts/identity/resource-http.test.ts index fd447e7f4..7d474b4e6 100644 --- a/tests/unit/control-plane/accounts/identity/resource-http.test.ts +++ b/tests/unit/control-plane/accounts/identity/resource-http.test.ts @@ -2,13 +2,14 @@ import { Hono } from 'hono'; import { expect, it, vi } from 'vitest'; import { decodeJwt, generateKeyPair, SignJWT } from 'jose'; import { BROWSER_SESSION_BRIDGE_PATH } from '@treeseed/sdk/identity'; +import { CONTROL_PLANE_OPERATION_LIST } from '@treeseed/sdk/operator-contracts'; import { installApiIdentityRoutes } from '../../../../../src/api/auth/browser/api-routes.ts'; import { createIdentityAuthenticator } from '../../../../../src/api/auth/identity-authenticator.ts'; import { controlPlaneOperations } from '../../../../../src/api/control-plane/catalog/index.ts'; import { OperationRegistry } from '../../../../../src/api/control-plane/catalog/operation-registry.ts'; const issuer = 'https://identity.example/realms/treeseed', resource = 'https://api.example'; -async function fixture() { +async function fixture(scopes = ['treeseed:read'], includeCommunication = false) { const keys = await generateKeyPair('RS256'); const first = vi.fn(async (sql: string) => sql.includes('identity_workloads') ? null : sql.includes('user_identities') ? { user_id: 'preserved-user', status: 'active' } : { id: 'preserved-user' }); @@ -20,11 +21,12 @@ async function fixture() { const observed: Array<{ clientId: string; expiresAt: number | undefined }> = []; const status = controlPlaneOperations.require('status.show'); installApiIdentityRoutes(app, { authenticate, services: new Map(), metadata: { - resource, authorization_servers: [issuer], bearer_methods_supported: ['header'], scopes_supported: ['treeseed:read'], + resource, authorization_servers: [issuer], bearer_methods_supported: ['header'], scopes_supported: scopes, } }, { registry: new OperationRegistry([{ ...status, handler: async (input, context) => { if (context.authInfo) observed.push({ clientId: context.authInfo.clientId, expiresAt: context.authInfo.expiresAt }); return status.handler(input, context); - } }]) }); + } }, ...(includeCommunication ? [{ binding: CONTROL_PLANE_OPERATION_LIST.find(operation => operation.descriptor.operationId === 'communications.send')!, + handler: async () => { throw new Error('Metadata reads must not invoke a mutation.'); } }] : [])]) }); const token = (audience = resource, scope = 'treeseed:read', clientId: unknown = 'admin') => new SignJWT({ typ: 'Bearer', azp: clientId, scope }) .setProtectedHeader({ alg: 'RS256' }).setIssuer(issuer).setAudience(audience).setSubject('exact-subject') .setIssuedAt().setExpirationTime('47s').sign(keys.privateKey); @@ -43,6 +45,46 @@ it('publishes configured resource metadata, never the incoming Host or an API-lo } }); +it('advertises only configured scopes of exposed MCP operations, preserving the general API metadata', async () => { + const scopes = ['treeseed:read', 'treeseed:admin', 'treeseed:execution', 'treeseed:projects:write']; + const { app } = await fixture(scopes, true); + const api = await (await app.request('/.well-known/oauth-protected-resource')).json(); + const mcp = await (await app.request('/.well-known/oauth-protected-resource/mcp')).json(); + expect(api).toMatchObject({ resource, scopes_supported: scopes }); + expect(mcp).toMatchObject({ resource, authorization_servers: [issuer], scopes_supported: ['treeseed:read', 'treeseed:execution'] }); +}); + +it('serves the native coordinator 2025 handshake, discovery and shared backend read with verified Identity tokens', async () => { + const { app, token, observed } = await fixture(); + const bearer = await token(resource, 'treeseed:read', 'native-coordinator'); + const request = (message: unknown, authorization = `Bearer ${bearer}`) => app.request('https://api.example/mcp', { + method: 'POST', headers: { host: 'api.example', authorization, 'content-type': 'application/json', + accept: 'application/json, text/event-stream', 'mcp-protocol-version': '2025-06-18' }, body: JSON.stringify(message), + }); + const payload = async (response: Response) => { + if (response.headers.get('content-type')?.includes('application/json')) return response.json(); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + return JSON.parse((await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n')); + }; + const initialize = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'native-coordinator', version: '1.0.0' }, + } }; + const initialized = await request(initialize); + expect(initialized.status).toBe(200); + expect(await payload(initialized)).toMatchObject({ id: 1, result: { protocolVersion: '2025-06-18', + serverInfo: { name: 'treeseed-control-plane' } } }); + const listed = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + expect(listed.status).toBe(200); + expect(await payload(listed)).toMatchObject({ id: 2, result: { tools: [expect.objectContaining({ name: 'status.show' })] } }); + const called = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { + name: 'status.show', arguments: { path: {}, query: {} }, + } }); + expect(called.status).toBe(200); + expect(await payload(called)).toMatchObject({ id: 3, result: { structuredContent: expect.any(Object) } }); + expect(observed).toEqual([{ clientId: 'native-coordinator', expiresAt: expect.any(Number) }]); + expect((await request(initialize, 'Bearer invalid')).status).toBe(401); +}); + it('uses verified Identity tokens for existing resource operations and denies wrong audiences without database reads', async () => { const { app, token, first } = await fixture(); expect((await app.request('/v1/status', { headers: { authorization: `Bearer ${await token()}` } })).status).toBe(200); diff --git a/tests/unit/control-plane/mcp/authorization-equivalence.test.ts b/tests/unit/control-plane/mcp/authorization-equivalence.test.ts index 4f07d4d49..138b152d9 100644 --- a/tests/unit/control-plane/mcp/authorization-equivalence.test.ts +++ b/tests/unit/control-plane/mcp/authorization-equivalence.test.ts @@ -8,15 +8,15 @@ import { sdkOperationInputStandardSchema, sdkSchemaJson, sdkStandardSchema } fro describe('MCP authorization equivalence', () => { it('retains real MCP mutation identity and version headers and denies missing authority before dispatch', async () => { - const binding = CONTROL_PLANE_OPERATIONS.projects.update; + const binding = CONTROL_PLANE_OPERATIONS.knowledge.updateWorkspaceContent; const observations: OperationInvocationContext[] = []; const registry = new OperationRegistry([{ binding, async handler(_input, context) { observations.push(context); - if (context.ifMatch !== 'revision-1') throw new ControlPlaneOperationError(412, 'project_version_changed', 'Read the current project revision before updating it.'); - return { id: 'project-api', updatedAt: 'revision-2' }; + if (context.ifMatch !== 'revision-1') throw new ControlPlaneOperationError(412, 'workspace_version_changed', 'Read the current workspace revision before updating it.'); + return { id: 'workspace-api', updatedAt: 'revision-2' }; } }]); const app = new Hono(); - installControlPlaneProtocolRoutes(app, async () => ({ principal: { id: 'coordinator', scopes: ['treeseed:projects:write'] }, + installControlPlaneProtocolRoutes(app, async () => ({ principal: { id: 'coordinator', scopes: ['treeseed:knowledge:write'] }, credential: { id: 'coordinator-client' } }), undefined, registry); let supplied: Record = {}; const client = new Client({ name: 'project-coordinator', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); @@ -28,7 +28,9 @@ describe('MCP authorization equivalence', () => { return app.fetch(new Request(request, { headers })); }, })); - const arguments_ = { path: { projectId: 'project-api' }, body: { name: 'API coordinator work' } }; + const arguments_ = { path: { workspaceId: 'workspace-api' }, body: { + kind: 'agent-profile', sourcePath: 'agents/engineer.yaml', version: 1, content: 'governed profile source', + } }; try { const missingKey = await client.callTool({ name: binding.descriptor.operationId, arguments: arguments_ }); expect(missingKey).toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringMatching(/Idempotency-Key/iu) }] }); @@ -39,13 +41,13 @@ describe('MCP authorization equivalence', () => { expect(observations).toEqual([]); supplied['if-match'] = '"revision-1"'; const updated = await client.callTool({ name: binding.descriptor.operationId, arguments: arguments_ }); - expect(updated).toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + expect(updated).toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations).toHaveLength(1); expect(observations[0]).toMatchObject({ interface: 'mcp', idempotencyKey: 'api-objective-update', ifMatch: 'revision-1', principal: { id: 'coordinator' } }); supplied['if-match'] = '"revision-stale"'; expect(await client.callTool({ name: binding.descriptor.operationId, arguments: arguments_ })) - .toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringMatching(/current project revision/iu) }] }); + .toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringMatching(/current workspace revision/iu) }] }); expect(observations).toHaveLength(2); expect(observations[1]).toMatchObject({ idempotencyKey: 'api-objective-update', ifMatch: 'revision-stale' }); const tool = (await client.listTools()).tools.find(value => value.name === binding.descriptor.operationId); @@ -53,7 +55,7 @@ describe('MCP authorization equivalence', () => { supplied = {}; const modelHeaders = { 'Idempotency-Key': 'model-api-objective-update', 'If-Match': '"revision-1"' }; expect(await client.callTool({ name: binding.descriptor.operationId, arguments: { ...arguments_, headers: modelHeaders } })) - .toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + .toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations[2]).toMatchObject({ idempotencyKey: 'model-api-objective-update', ifMatch: 'revision-1' }); for (const headers of [{ ...modelHeaders, 'If-Match': '' }, { ...modelHeaders, 'Idempotency-Key': ' ' }, { ...modelHeaders, 'Idempotency-Key': 7 }, { ...modelHeaders, 'Idempotency-Key': 'key\r\ninjected' }, @@ -68,12 +70,12 @@ describe('MCP authorization equivalence', () => { expect(observations).toHaveLength(3); supplied = { 'Idempotency-Key': ' model-api-objective-update ', 'If-Match': ' "revision-1" ' }; expect(await client.callTool({ name: binding.descriptor.operationId, arguments: { ...arguments_, headers: modelHeaders } })) - .toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + .toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations[3]).toMatchObject({ idempotencyKey: 'model-api-objective-update', ifMatch: 'revision-1' }); supplied = {}; const parallel = ['first-model-request', 'second-model-request'].map(key => client.callTool({ name: binding.descriptor.operationId, arguments: { ...arguments_, headers: { ...modelHeaders, 'Idempotency-Key': key } } })); - for (const result of await Promise.all(parallel)) expect(result).toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + for (const result of await Promise.all(parallel)) expect(result).toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations.slice(4).map(value => value.idempotencyKey).sort()).toEqual(['first-model-request', 'second-model-request']); } finally { await client.close(); } }); diff --git a/tests/unit/control-plane/mcp/legacy-confirmation.test.ts b/tests/unit/control-plane/mcp/legacy-confirmation.test.ts new file mode 100644 index 000000000..2698d1ebb --- /dev/null +++ b/tests/unit/control-plane/mcp/legacy-confirmation.test.ts @@ -0,0 +1,42 @@ +import { Hono } from 'hono'; +import { describe, expect, it } from 'vitest'; +import { CONTROL_PLANE_OPERATIONS } from '@treeseed/sdk/operator-contracts'; +import { OperationRegistry } from '../../../../src/api/control-plane/catalog/operation-registry.ts'; +import { ConfirmationService, encodeConfirmation } from '../../../../src/api/control-plane/confirmation/confirmation-service.ts'; +import { installControlPlaneProtocolRoutes } from '../../../../src/api/control-plane/http/protocol-routes.ts'; + +describe('stateless native MCP confirmation', () => { + it('returns and consumes the existing signed confirmation without server-to-client requests', async () => { + const calls: unknown[] = [], used = new Set(); + const confirmations = new ConfirmationService('controlled-legacy-confirmation-secret', { async consume(nonce) { + if (used.has(nonce)) return false; used.add(nonce); return true; + } }); + const app = new Hono(), binding = CONTROL_PLANE_OPERATIONS.workdays.start; + installControlPlaneProtocolRoutes(app, async token => ({ principal: { id: token, scopes: ['treeseed:execution'] }, credential: { id: 'client' } }), undefined, + new OperationRegistry([{ binding, async handler(input) { calls.push(input); return { started: true }; } }]), confirmations); + const args = { path: { teamId: 'team' }, query: {}, body: { preflightId: 'real-preflight', preflightDigest: 'sha256:inspected' }, headers: { 'Idempotency-Key': 'start-real-work' } }; + const call = async (input: unknown, token = 'coordinator') => { + const response = await app.request('/mcp', { method: 'POST', headers: { host: 'localhost', authorization: `Bearer ${token}`, + 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'mcp-protocol-version': '2025-06-18' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: binding.descriptor.operationId, arguments: input } }) }); + const wire = await response.text(); + const payload = response.headers.get('content-type')?.includes('text/event-stream') + ? wire.split('\n').find(line => line.startsWith('data: '))!.slice(6) : wire; + return JSON.parse(payload).result; + }; + const first = await call(args); + expect(first.isError).toBe(true); expect(calls).toEqual([]); + const required = JSON.parse(first.content[0].text); + expect(required.type).toBe('input_required'); expect(required.confirmation.operationId).toBe('workdays.start'); + const confirmed = { ...args, headers: { ...args.headers, 'x-treeseed-confirmation': encodeConfirmation(required.confirmation) } }; + for (const invalid of [ + { ...confirmed, body: { ...args.body, preflightId: 'different' } }, + { ...confirmed, headers: { ...confirmed.headers, 'Idempotency-Key': 'different' } }, + { ...confirmed, headers: { ...confirmed.headers, 'x-treeseed-confirmation': 'malformed' } }, + ]) { expect((await call(invalid)).isError).toBe(true); expect(calls).toEqual([]); } + expect((await call(confirmed, 'other-principal')).isError).toBe(true); expect(calls).toEqual([]); + expect((await call(confirmed)).structuredContent).toEqual({ started: true }); + expect(calls).toEqual([{ path: args.path, query: {}, body: args.body }]); + expect((await call(confirmed)).isError).toBe(true); expect(calls).toHaveLength(1); + }); +}); diff --git a/tests/unit/control-plane/mcp/schema-dialect.test.ts b/tests/unit/control-plane/mcp/schema-dialect.test.ts index 32f414bad..41ef4636f 100644 --- a/tests/unit/control-plane/mcp/schema-dialect.test.ts +++ b/tests/unit/control-plane/mcp/schema-dialect.test.ts @@ -2,6 +2,10 @@ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ import { CONTROL_PLANE_OPERATIONS, type ControlPlaneOperationBinding } from '@treeseed/sdk/operator-contracts'; import { describe, expect, it } from 'vitest'; import { z } from 'zod/v3'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { Hono } from 'hono'; +import { OperationRegistry } from '../../../../src/api/control-plane/catalog/operation-registry.ts'; +import { installControlPlaneProtocolRoutes } from '../../../../src/api/control-plane/http/protocol-routes.ts'; import { sdkOperationInputStandardSchema, sdkSchemaJson, sdkStandardSchema } from '../../../../src/api/control-plane/catalog/sdk-standard-schema.ts'; function bindings(value: unknown): ControlPlaneOperationBinding[] { @@ -11,6 +15,42 @@ function bindings(value: unknown): ControlPlaneOperationBinding[] { } describe('MCP JSON Schema dialect', () => { + it('advertises and validates the canonical body for read-only POST planning', () => { + const binding = CONTROL_PLANE_OPERATIONS.agents.teamClone.plan; + const schema = sdkOperationInputStandardSchema(binding)['~standard']; + const body = { sourceProject: 'api', targetProjects: ['admin'] }; + expect(schema.jsonSchema.input().properties).toHaveProperty('body'); + expect(schema.validate({ path: { teamId: 'team' }, body })).toEqual({ value: { path: { teamId: 'team' }, query: {}, body } }); + for (const invalid of [undefined, {}, { sourceProject: 'api' }, { ...body, allEligible: true }, { ...body, unexpected: true }]) + expect(schema.validate({ path: { teamId: 'team' }, body: invalid })).toHaveProperty('issues'); + expect(schema.jsonSchema.input().properties).not.toHaveProperty('headers'); + }); + it('delivers read-only POST input through the official MCP client and shared HTTP adapter', async () => { + const binding = CONTROL_PLANE_OPERATIONS.agents.teamClone.plan, calls: unknown[] = []; + const body = { sourceProject: 'api', targetProjects: ['admin'] }; + const project = { projectId: 'api', name: 'API', slug: 'api', repository: 'api-library', commit: 'a'.repeat(40) }; + const plan = { schemaVersion: 'treeseed.agent-team-clone-plan/v1', teamId: 'team', source: project, + targets: [{ ...project, projectId: 'admin', action: 'noop', definitions: [{ path: 'agents/engineer.yaml', + agentClass: 'engineer', sourceDigest: `sha256:${'b'.repeat(64)}`, desiredDigest: `sha256:${'b'.repeat(64)}` }] }], digest: `sha256:${'c'.repeat(64)}` }; + const registry = new OperationRegistry([{ binding, handler: async input => { calls.push(input.body); return plan; } }]); + const app = new Hono(); + installControlPlaneProtocolRoutes(app, async () => ({ principal: { id: 'user', scopes: ['treeseed:read'] }, credential: { id: 'client' } }), undefined, registry); + const client = new Client({ name: 'read-plan-input', version: '1' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + try { + await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + authProvider: { token: async () => 'controlled-principal' }, fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init), headers = new Headers(request.headers); + headers.set('host', 'localhost'); return app.fetch(new Request(request, { headers })); + }, + })); + const tools = await client.listTools(); + expect(tools.tools[0]!.inputSchema.properties).toHaveProperty('body'); + const result = await client.callTool({ name: binding.descriptor.operationId, arguments: { path: { teamId: 'team' }, body } }); + expect(result.isError).not.toBe(true); expect(result.structuredContent).toEqual(plan); expect(calls).toEqual([body]); + const denied = await client.callTool({ name: binding.descriptor.operationId, arguments: { path: { teamId: 'team' }, body: { ...body, allEligible: true } } }); + expect(denied.isError).toBe(true); expect(calls).toEqual([body]); + } finally { await client.close(); } + }); it('compiles every declared MCP tool input and output with the official client validator', () => { const exposed = bindings(CONTROL_PLANE_OPERATIONS).filter(binding => binding.descriptor.surfaces.includes('mcp_tool')); expect(exposed.map(binding => binding.descriptor.operationId)).toContain('communications.send'); diff --git a/tests/unit/control-plane/protocol-contract.test.ts b/tests/unit/control-plane/protocol-contract.test.ts index 5b750397e..7d37fa293 100644 --- a/tests/unit/control-plane/protocol-contract.test.ts +++ b/tests/unit/control-plane/protocol-contract.test.ts @@ -401,19 +401,6 @@ describe('control-plane protocol contract', () => { expect(registry.require(operationId).binding).toBe(Object.values(CONTROL_PLANE_OPERATIONS.teams).find((binding) => binding.descriptor.operationId === operationId)); } }); - it('rejects legacy MCP initialization traffic', async () => { - const app = new Hono(); - installControlPlaneProtocolRoutes(app, authenticate); - const response = await app.request('/mcp', { - method: 'POST', - headers: { authorization: 'Bearer test-token', 'content-type': 'application/json', host: 'localhost' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1' } } }), - }); - const body = await response.text(); - expect(body).toContain('Unsupported protocol version'); - expect(body).toContain('2026-07-28'); - }); - it('serves discovery and tools through the official modern client', async () => { const app = new Hono(); const registry = createApiControlPlaneOperations(apiDependencies()); @@ -462,9 +449,12 @@ describe('control-plane protocol contract', () => { it('completes signed high-risk confirmation through official MCP multi-round input', async () => { const app = new Hono(); - const registry = createApiControlPlaneOperations(apiDependencies()); + const observed: unknown[] = []; + const registry = new OperationRegistry([{ binding: CONTROL_PLANE_OPERATIONS.workdays.stop, async handler(input) { + observed.push(input); return { id: input.path.runId }; + } }]); installControlPlaneProtocolRoutes(app, async () => ({ - principal: { id: 'user_1', scopes: ['treeseed:read', 'treeseed:projects:write'], roles: ['admin'], permissions: [] }, + principal: { id: 'user_1', scopes: ['treeseed:execution'], roles: [], permissions: [] }, credential: { id: 'client_1' }, }), oauthProvider, registry, confirmationService()); const transport = new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { @@ -473,8 +463,7 @@ describe('control-plane protocol contract', () => { const request = input instanceof Request ? input : new Request(input, init); const headers = new Headers(request.headers); headers.set('host', 'localhost'); - headers.set('Idempotency-Key', 'archive-project-a'); - headers.set('If-Match', '"revision-1"'); + headers.set('Idempotency-Key', 'stop-workday-a'); return app.fetch(new Request(request, { headers })); }, }); @@ -486,11 +475,12 @@ describe('control-plane protocol contract', () => { client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } })); await client.connect(transport); try { - const result = await client.callTool({ name: 'projects.archive', arguments: { - path: { projectId: 'project-a' }, query: {}, body: {}, + const result = await client.callTool({ name: 'workdays.stop', arguments: { + path: { teamId: 'team-a', runId: 'workday-a' }, query: {}, body: {}, } }); expect(result.isError).not.toBe(true); - expect(result.structuredContent).toMatchObject({ id: 'project-a' }); + expect(result.structuredContent).toMatchObject({ id: 'workday-a' }); + expect(observed).toEqual([{ path: { teamId: 'team-a', runId: 'workday-a' }, query: {}, body: {} }]); } finally { await client.close(); }