Skip to content

Commit 68d0ca3

Browse files
authored
feat: auto spec discovery (#28)
1 parent 4710b2a commit 68d0ca3

22 files changed

Lines changed: 1079 additions & 13788 deletions

openapi-builds.json

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,53 +22,40 @@
2222
"label": "2024",
2323
"url": "https://cumulocity.com/api/core/2024/dist/c8y-oas.json"
2424
}
25-
],
26-
"dtm": [
27-
{
28-
"version": "release",
29-
"label": "release (latest)",
30-
"url": "https://cumulocity.com/api/dtm/dist/c8y-dtm-oas.json",
31-
"servicePrefix": "/service/dtm",
32-
"default": true
33-
}
3425
]
3526
},
3627
"builds": [
3728
{
3829
"version": "release",
3930
"label": "release (latest)",
40-
"artifact": "core-release-dtm",
31+
"artifact": "core-release",
4132
"default": true,
4233
"apis": {
43-
"core": "release",
44-
"dtm": "release"
34+
"core": "release"
4535
}
4636
},
4737
{
4838
"version": "2026",
4939
"label": "2026",
50-
"artifact": "core-2026-dtm",
40+
"artifact": "core-2026",
5141
"apis": {
52-
"core": "2026",
53-
"dtm": "release"
42+
"core": "2026"
5443
}
5544
},
5645
{
5746
"version": "2025",
5847
"label": "2025",
59-
"artifact": "core-2025-dtm",
48+
"artifact": "core-2025",
6049
"apis": {
61-
"core": "2025",
62-
"dtm": "release"
50+
"core": "2025"
6351
}
6452
},
6553
{
6654
"version": "2024",
6755
"label": "2024",
68-
"artifact": "core-2024-dtm",
56+
"artifact": "core-2024",
6957
"apis": {
70-
"core": "2024",
71-
"dtm": "release"
58+
"core": "2024"
7259
}
7360
}
7461
]

openapi/dtm/release.json

Lines changed: 0 additions & 13134 deletions
This file was deleted.

src/cli/index.ts

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { defineCommand, runMain } from 'citty'
44
import consola from 'consola'
55
import pkgjson from '../../package.json' with { type: 'json' }
66
import { getCoreOpenApiLabel, getCoreOpenApiVersion, setCoreOpenApiVersion, specs } from '#core-openapi'
7-
import { createC8YMcpServer } from '../server'
7+
import { c8yMcpServer, setupMcpServer } from '../server'
88
import { getCredentialsByTenantUrl, getStoredC8yAuth } from '../utils/credentials'
9-
import { createOpenApiPartRestrictionRules } from '../utils/openapi'
10-
import { parseAllowRule, parseDisabledOpenApiParts, parseRestrictionRule } from '../utils/restrictions'
9+
import { getAllReadySpecs, startDiscovery } from '../utils/api-discovery'
10+
import { createC8yAuthHeaders } from '../utils/client'
11+
import { parseAllowRule, parseRestrictionRule } from '../utils/restrictions'
1112

1213
const main = defineCommand({
1314
meta: {
@@ -26,11 +27,6 @@ const main = defineCommand({
2627
description: 'Allow rule to permit API access (for example "GET:/inventory/**"). Can be repeated. When present, non-matching operations are blocked unless another allow rule matches them.',
2728
alias: ['a', 'allow'],
2829
},
29-
disableOpenapi: {
30-
type: 'string',
31-
description: 'Bundled OpenAPI parts to forbid for execute policy. Repeat to disable one or more spec families such as "dtm". Query still sees all bundled specs.',
32-
alias: 'd',
33-
},
3430
spec: {
3531
type: 'string',
3632
description: `Bundled core OpenAPI snapshot to expose to query. Available: ${specs.map((entry) => `${entry.version} (${entry.label})`).join(', ')}.`,
@@ -91,35 +87,30 @@ const main = defineCommand({
9187
consola.info(`Applying ${parsedAllowRules.length} allow rule(s):`, parsedAllowRules.map((rule) => rule.source))
9288
}
9389

94-
const rawOpenApi = args.disableOpenapi
95-
const openApiSources = (Array.isArray(rawOpenApi) ? rawOpenApi : rawOpenApi ? [rawOpenApi] : []).filter(
96-
(value): value is string => typeof value === 'string' && value.length > 0,
97-
)
98-
const { disabledApis, failedValues } = parseDisabledOpenApiParts(openApiSources)
99-
100-
if (failedValues.length > 0) {
101-
throw new Error([
102-
'One or more OpenAPI flags could not be parsed:',
103-
...failedValues.map((value) => `- ${value.value}: ${value.reason}`),
104-
].join('\n'))
105-
}
106-
107-
if (disabledApis.length > 0) {
108-
consola.info(`Disabled bundled OpenAPI parts for execute policy on this connection:`, disabledApis)
90+
// Fire off background discovery for all stored tenants.
91+
// The MCP server starts immediately — tool handlers await the results lazily.
92+
const storedCreds = await getStoredC8yAuth()
93+
for (const creds of storedCreds) {
94+
const headers = createC8yAuthHeaders(creds)
95+
startDiscovery(creds.tenantUrl, headers)
96+
.then((specs) => {
97+
if (specs.length > 0) {
98+
consola.info(`[${creds.tenantUrl}] Discovered: ${specs.map((s) => `${s.specLabel} (${s.contextPath})`).join(', ')}`)
99+
} else {
100+
consola.info(`[${creds.tenantUrl}] No microservice API specs found`)
101+
}
102+
})
103+
.catch((err: unknown) => {
104+
consola.warn(`[${creds.tenantUrl}] API spec discovery failed:`, err instanceof Error ? err.message : String(err))
105+
})
109106
}
110107

111-
// When a connection forbids bundled OpenAPI parts, expand that selection into
112-
// concrete restriction rules here so execute enforcement can stay purely path/method-based.
113-
const effectiveRestrictions = disabledApis.length > 0
114-
? [...restrictions, ...createOpenApiPartRestrictionRules(disabledApis)]
115-
: restrictions
116-
117-
const server = createC8YMcpServer()
108+
setupMcpServer()
118109

119110
// Start the server with stdio transport
120-
const transport = new StdioTransport(server)
111+
const transport = new StdioTransport(c8yMcpServer)
121112
consola.info('Starting MCP server over stdio transport...')
122-
transport.listen({ restrictions: effectiveRestrictions, allowRules: parsedAllowRules, disabledApis })
113+
transport.listen({ restrictions, allowRules: parsedAllowRules, discoveredSpecs: getAllReadySpecs() })
123114
},
124115
})
125116

src/codemode/execute.ts

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { NodeRuntime, createNodeDriver, createNodeRuntimeDriverFactory } from 's
33
import { AsyncSemaphore } from './semaphore'
44
import { createNetworkPermissionDecision } from './network-permissions'
55
import { createC8yAuthHeaders, resolveC8yAuth } from '../utils/client'
6+
import { c8yMcpServer } from '../server-instance'
7+
import type { DiscoveredApiSpec } from '../utils/api-discovery'
68
import {
79
compileRestrictionRule,
810
compileRestrictionSegment,
@@ -14,6 +16,14 @@ import { BUNDLED_OPENAPI_SPECS } from '../utils/openapi'
1416
import { HTTP_METHODS } from '../utils/restrictions'
1517
import type { AllowRule, RestrictionRule } from '../utils/restrictions'
1618

19+
function getContextRestrictions(): readonly RestrictionRule[] {
20+
return c8yMcpServer.ctx.custom?.restrictions ?? []
21+
}
22+
23+
function getContextAllowRules(): readonly AllowRule[] {
24+
return c8yMcpServer.ctx.custom?.allowRules ?? []
25+
}
26+
1727
const NO_DEFAULT_EXPORT_MESSAGE = 'Execution completed without returning a value.'
1828
const QUERY_ENTRY_PATH = '/codemode-query.mjs'
1929
const EXECUTE_ENTRY_PATH = '/codemode-execute.mjs'
@@ -39,7 +49,6 @@ function serializeExecuteConfig(
3949
headers: Record<string, string>,
4050
restrictions: readonly RestrictionRule[],
4151
allowRules: readonly AllowRule[],
42-
disabledApis: readonly string[],
4352
): string {
4453
const normalizedTenantUrl = new URL(tenantUrl).toString()
4554
const serializedRestrictions = restrictions.map(({ type, method, pathPattern, source }) => ({ type, method, pathPattern, source }))
@@ -50,7 +59,6 @@ function serializeExecuteConfig(
5059
authHeaders: headers,
5160
restrictions: serializedRestrictions,
5261
allowRules: serializedAllowRules,
53-
disabledApis,
5462
})
5563
}
5664

@@ -72,13 +80,12 @@ function createExecuteRuntime(
7280
tenantUrl: string,
7381
restrictions: readonly RestrictionRule[],
7482
allowRules: readonly AllowRule[],
75-
disabledApis: readonly string[],
7683
) {
7784
return new NodeRuntime({
7885
systemDriver: createNodeDriver({
7986
useDefaultNetwork: true,
8087
permissions: {
81-
network: (request) => createNetworkPermissionDecision(tenantUrl, request, restrictions, allowRules, disabledApis),
88+
network: (request) => createNetworkPermissionDecision(tenantUrl, request, restrictions, allowRules),
8289
},
8390
}),
8491
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
@@ -98,19 +105,20 @@ function normalizeCode(functionCode: string): string {
98105
return normalized
99106
}
100107

101-
function buildQueryScript(
102-
sourceCode: string,
103-
disabledApis: readonly string[],
104-
): string {
108+
function buildQueryScript(sourceCode: string, discoveredSpecs?: readonly DiscoveredApiSpec[]): string {
105109
const functionExpression = normalizeCode(sourceCode)
106110

111+
// Server mode: read from per-request context (set by H3 handler).
112+
// CLI mode: caller resolves the right tenant's specs and passes them in.
113+
const resolvedSpecs = discoveredSpecs ?? c8yMcpServer.ctx.custom?.discoveredSpecs ?? []
114+
const serviceSpecsMap: Record<string, { label: string, contextPath: string, spec: unknown }> = {}
115+
for (const ds of resolvedSpecs) {
116+
serviceSpecsMap[ds.contextPath] = { label: ds.specLabel, contextPath: ds.contextPath, spec: ds.spec }
117+
}
118+
107119
return [
108120
`const coreSpec = ${JSON.stringify(BUNDLED_OPENAPI_SPECS.core)};`,
109-
`const dtmSpec = ${JSON.stringify(BUNDLED_OPENAPI_SPECS.dtm)};`,
110-
`const specsEnabled = ${JSON.stringify({
111-
core: !disabledApis.includes('core'),
112-
dtm: !disabledApis.includes('dtm'),
113-
})};`,
121+
`const serviceSpecs = ${JSON.stringify(serviceSpecsMap)};`,
114122
`const __mc8ypQuery = (${functionExpression});`,
115123
'',
116124
'if (typeof __mc8ypQuery !== "function") {',
@@ -126,17 +134,16 @@ export function buildExecutePrelude(
126134
headers: Record<string, string>,
127135
restrictions: readonly RestrictionRule[] = [],
128136
allowRules: readonly AllowRule[] = [],
129-
disabledApis: readonly string[] = [],
130137
): string {
131-
const serializedConfig = JSON.stringify(serializeExecuteConfig(tenantUrl, headers, restrictions, allowRules, disabledApis))
138+
const serializedConfig = JSON.stringify(serializeExecuteConfig(tenantUrl, headers, restrictions, allowRules))
132139

133140
return [
134141
'const cumulocity = Object.freeze((() => {',
135142
` const config = JSON.parse(${serializedConfig});`,
136143
' if (!config || typeof config !== "object") {',
137144
' throw new TypeError("Invalid execute configuration.");',
138145
' }',
139-
' const { tenantUrl, authHeaders, restrictions, allowRules, disabledApis } = config;',
146+
' const { tenantUrl, authHeaders, restrictions, allowRules } = config;',
140147
' if (typeof tenantUrl !== "string") {',
141148
' throw new TypeError("Execute configuration must contain a string tenant URL.");',
142149
' }',
@@ -146,9 +153,7 @@ export function buildExecutePrelude(
146153
' if (!Array.isArray(allowRules) || allowRules.some((rule) => !rule || typeof rule !== "object" || typeof rule.type !== "string" || typeof rule.method !== "string" || typeof rule.pathPattern !== "string" || typeof rule.source !== "string")) {',
147154
' throw new TypeError("Execute configuration must contain valid allow rules.");',
148155
' }',
149-
' if (!Array.isArray(disabledApis) || disabledApis.some((value) => typeof value !== "string")) {',
150-
' throw new TypeError("Execute configuration must contain valid disabled API parts.");',
151-
' }',
156+
152157
' const resolveUrl = (descriptor) => {',
153158
' return new URL(descriptor, tenantUrl.endsWith("/") ? tenantUrl : tenantUrl + "/");',
154159
' };',
@@ -203,7 +208,6 @@ export function buildExecutePrelude(
203208
' "",',
204209
' "Report this to the user as a connection-level access restriction.",',
205210
' "If the operation is needed, the MCP allow list for this connection must be updated by whoever manages that configuration.",',
206-
' ...(disabledApis.length > 0 ? ["", "Disabled bundled OpenAPI parts for execute policy on this connection:", ...disabledApis.map((api) => "- " + api), "Endpoints from those bundled specs are blocked by connection-level bundled OpenAPI policy."] : []),',
207211
' "",',
208212
' "Blocked operation:",',
209213
' "Method: " + method,',
@@ -221,7 +225,6 @@ export function buildExecutePrelude(
221225
' "",',
222226
' "Report this to the user as a connection-level access restriction.",',
223227
' "If the operation is needed, the MCP restrictions for this connection must be updated by whoever manages that configuration.",',
224-
' ...(disabledApis.length > 0 ? ["", "Disabled bundled OpenAPI parts for execute policy on this connection:", ...disabledApis.map((api) => "- " + api), "Endpoints from those bundled specs are blocked by connection-level bundled OpenAPI policy."] : []),',
225228
' "",',
226229
' "Blocked operation:",',
227230
' "Method: " + method,',
@@ -295,12 +298,11 @@ export function buildExecuteScript(
295298
headers: Record<string, string>,
296299
restrictions: readonly RestrictionRule[] = [],
297300
allowRules: readonly AllowRule[] = [],
298-
disabledApis: readonly string[] = [],
299301
): string {
300302
const functionExpression = normalizeCode(sourceCode)
301303

302304
return [
303-
buildExecutePrelude(tenantUrl, headers, restrictions, allowRules, disabledApis),
305+
buildExecutePrelude(tenantUrl, headers, restrictions, allowRules),
304306
`const __mc8ypExecute = (${functionExpression});`,
305307
'',
306308
'const __mc8ypErrorMessage = (error) => error instanceof Error ? error.message : String(error);',
@@ -347,10 +349,9 @@ async function runExecuteScript(
347349
tenantUrl: string,
348350
restrictions: readonly RestrictionRule[],
349351
allowRules: readonly AllowRule[],
350-
disabledApis: readonly string[],
351352
): Promise<unknown> {
352353
const release = await runtimeSemaphore.acquire()
353-
const runtime = createExecuteRuntime(tenantUrl, restrictions, allowRules, disabledApis)
354+
const runtime = createExecuteRuntime(tenantUrl, restrictions, allowRules)
354355

355356
try {
356357
const result = await runtime.run<unknown>(code, EXECUTE_ENTRY_PATH)
@@ -389,32 +390,25 @@ async function runModule(code: string, entryPath: string, runtime: NodeRuntime):
389390
}
390391
}
391392

392-
export async function query(
393-
functionCode: string,
394-
_restrictions: readonly RestrictionRule[] = [],
395-
_allowRules: readonly AllowRule[] = [],
396-
disabledApis: readonly string[] = [],
397-
): Promise<string> {
398-
const result = await runQueryScript(buildQueryScript(functionCode, disabledApis))
393+
export async function query(functionCode: string, discoveredSpecs?: readonly DiscoveredApiSpec[]): Promise<string> {
394+
const result = await runQueryScript(buildQueryScript(functionCode, discoveredSpecs))
399395
return typeof result === 'string' ? result : JSON.stringify(result)
400396
}
401397

402398
export async function execute(
403399
functionCode: string,
404400
input?: unknown,
405-
restrictions: readonly RestrictionRule[] = [],
406-
allowRules: readonly AllowRule[] = [],
407-
disabledApis: readonly string[] = [],
408401
): Promise<string> {
409402
const auth = await resolveC8yAuth(input)
410403
const authHeaders = createC8yAuthHeaders(auth)
404+
const restrictions = getContextRestrictions()
405+
const allowRules = getContextAllowRules()
411406

412407
const result = await runExecuteScript(
413-
buildExecuteScript(functionCode, auth.tenantUrl, authHeaders, restrictions, allowRules, disabledApis),
408+
buildExecuteScript(functionCode, auth.tenantUrl, authHeaders, restrictions, allowRules),
414409
auth.tenantUrl,
415410
restrictions,
416411
allowRules,
417-
disabledApis,
418412
) as ExecuteEnvelope
419413

420414
if (result.status === 'success') {

src/codemode/network-permissions.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export function createNetworkPermissionDecision(
1313
request: NetworkPermissionRequest,
1414
restrictions: readonly RestrictionRule[] = [],
1515
allowRules: readonly AllowRule[] = [],
16-
disabledApis: readonly string[] = [],
1716
): NetworkPermissionDecision {
1817
const tenantHostname = new URL(tenantUrl).hostname
1918

@@ -45,7 +44,7 @@ export function createNetworkPermissionDecision(
4544

4645
return {
4746
allow: false,
48-
reason: `Network connect blocked by MCP allow list: no allow rule matched ${normalizedMethod} ${pathname}. Configured allow rules: ${allowRules.map((rule) => rule.source).join(', ')}${disabledApis.length > 0 ? `. Disabled bundled OpenAPI parts for execute policy on this connection: ${disabledApis.join(', ')}. Endpoints from those bundled specs are blocked by connection-level bundled OpenAPI policy.` : ''}`,
47+
reason: `Network connect blocked by MCP allow list: no allow rule matched ${normalizedMethod} ${pathname}. Configured allow rules: ${allowRules.map((rule) => rule.source).join(', ')}`,
4948
}
5049
}
5150
} else {

0 commit comments

Comments
 (0)