Skip to content

Commit 91f4ed4

Browse files
feat: improve error handling and validation in skills manifest and related files (#316)
* feat: improve error handling and validation in skills manifest and related files * feat: add frontmatter metadata to various documentation files for improved organization and clarity * refactor: clean up code by removing unnecessary comments and improving variable names for clarity * refactor: clean up code by removing unnecessary comments and improving variable names for clarity * refactor: simplify debug provider tests and remove unnecessary properties from responses * refactor: simplify debug provider tests and remove unnecessary properties from responses
1 parent 79a156e commit 91f4ed4

119 files changed

Lines changed: 4220 additions & 529 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/codeql.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ name: "CodeQL Advanced"
22

33
on:
44
push:
5-
branches: ["main"]
5+
branches:
6+
- main
7+
- "next/**"
8+
- "release/**"
69
pull_request:
7-
branches: ["main"]
10+
branches:
11+
- main
12+
- "next/**"
13+
- "release/**"
814
schedule:
915
- cron: "16 7 * * 4"
1016

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* E2E Tests: Resource Provider Resolution & Plugin Context Extensions
3+
*
4+
* Verifies that:
5+
* 1. App-level providers registered via @App({ providers: [...] }) are accessible
6+
* from resources via this.get(Token), sharing the same GLOBAL singleton as tools.
7+
* 2. Plugin providers exposed via contextExtensions (e.g., this.counter) work
8+
* correctly in resource contexts, not just tool contexts.
9+
*/
10+
import { test, expect } from '@frontmcp/testing';
11+
12+
/**
13+
* Extract JSON content from a resource read result.
14+
* Resources return { contents: [{ text: '...' }] } so we parse the text.
15+
*/
16+
function extractResourceJson<T>(result: unknown): T {
17+
const raw = result as { raw?: { contents?: Array<{ text?: string }> } };
18+
const text = raw?.raw?.contents?.[0]?.text;
19+
if (!text) throw new Error('No text content in resource result');
20+
return JSON.parse(text) as T;
21+
}
22+
23+
/**
24+
* Extract structured content from a tool call result.
25+
*/
26+
function extractToolJson<T>(result: unknown): T {
27+
const raw = result as { raw?: { structuredContent?: T; content?: Array<{ text?: string }> } };
28+
if (raw?.raw?.structuredContent) return raw.raw.structuredContent;
29+
const text = raw?.raw?.content?.[0]?.text;
30+
if (!text) throw new Error('No content in tool result');
31+
return JSON.parse(text) as T;
32+
}
33+
34+
test.describe('Resource Provider Resolution E2E', () => {
35+
test.use({
36+
server: 'apps/e2e/demo-e2e-resource-providers/src/main.ts',
37+
project: 'demo-e2e-resource-providers',
38+
publicMode: true,
39+
});
40+
41+
// ─── Discovery ──────────────────────────────────────────────────────────
42+
43+
test.describe('Discovery', () => {
44+
test('should list all tools', async ({ mcp }) => {
45+
const tools = await mcp.tools.list();
46+
expect(tools).toContainTool('store_set');
47+
expect(tools).toContainTool('store_get');
48+
expect(tools).toContainTool('counter_increment');
49+
expect(tools).toContainTool('debug_providers');
50+
});
51+
52+
test('should list all resources', async ({ mcp }) => {
53+
const resources = await mcp.resources.list();
54+
expect(resources).toContainResource('store://contents');
55+
expect(resources).toContainResource('counter://status');
56+
expect(resources).toContainResource('debug://providers');
57+
});
58+
});
59+
60+
// ─── App-level provider in resource via this.get() ─────────────────────
61+
62+
test.describe('App Provider in Resource', () => {
63+
test('tool can resolve app provider via this.get()', async ({ mcp }) => {
64+
const result = await mcp.tools.call('store_set', { key: 'test', value: 'hello' });
65+
expect(result).toBeSuccessful();
66+
expect(result).toHaveTextContent('storeInstanceId');
67+
});
68+
69+
test('resource can resolve same app provider via this.get()', async ({ mcp }) => {
70+
const resource = await mcp.resources.read('store://contents');
71+
expect(resource).toBeSuccessful();
72+
expect(resource).toHaveTextContent('storeInstanceId');
73+
});
74+
75+
test('resource and tool share the same GLOBAL provider instance', async ({ mcp }) => {
76+
// Store a value via tool
77+
const setResult = await mcp.tools.call('store_set', { key: 'shared-test', value: 'from-tool' });
78+
expect(setResult).toBeSuccessful();
79+
80+
// Read back via resource — should see the same data (same singleton)
81+
const resource = await mcp.resources.read('store://contents');
82+
expect(resource).toBeSuccessful();
83+
expect(resource).toHaveTextContent('shared-test');
84+
expect(resource).toHaveTextContent('from-tool');
85+
86+
// Compare storeInstanceId
87+
const toolData = extractToolJson<{ storeInstanceId: string }>(setResult);
88+
const resourceData = extractResourceJson<{ storeInstanceId: string }>(resource);
89+
90+
expect(toolData.storeInstanceId).toBeDefined();
91+
expect(resourceData.storeInstanceId).toBeDefined();
92+
expect(toolData.storeInstanceId).toBe(resourceData.storeInstanceId);
93+
});
94+
95+
test('resource sees data written by tool (shared state)', async ({ mcp }) => {
96+
await mcp.tools.call('store_set', { key: 'cross-check', value: 'works' });
97+
const resource = await mcp.resources.read('store://contents');
98+
expect(resource).toBeSuccessful();
99+
expect(resource).toHaveTextContent('cross-check');
100+
101+
const getResult = await mcp.tools.call('store_get', { key: 'cross-check' });
102+
expect(getResult).toBeSuccessful();
103+
expect(getResult).toHaveTextContent('works');
104+
});
105+
});
106+
107+
// ─── Plugin context extension in resource ──────────────────────────────
108+
109+
test.describe('Plugin Context Extension in Resource', () => {
110+
test('tool can access plugin context extension (this.counter)', async ({ mcp }) => {
111+
const result = await mcp.tools.call('counter_increment', {});
112+
expect(result).toBeSuccessful();
113+
expect(result).toHaveTextContent('counterInstanceId');
114+
});
115+
116+
test('resource can access plugin context extension (this.counter)', async ({ mcp }) => {
117+
const resource = await mcp.resources.read('counter://status');
118+
expect(resource).toBeSuccessful();
119+
expect(resource).toHaveTextContent('counterInstanceId');
120+
});
121+
122+
test('resource and tool share same plugin provider instance', async ({ mcp }) => {
123+
// Increment via tool
124+
const inc1 = await mcp.tools.call('counter_increment', {});
125+
expect(inc1).toBeSuccessful();
126+
const inc2 = await mcp.tools.call('counter_increment', {});
127+
expect(inc2).toBeSuccessful();
128+
129+
// Read counter status via resource
130+
const resource = await mcp.resources.read('counter://status');
131+
expect(resource).toBeSuccessful();
132+
133+
// Counter was incremented twice, so count should be >= 2
134+
const resourceData = extractResourceJson<{ count: number; counterInstanceId: string }>(resource);
135+
expect(resourceData.count).toBeGreaterThanOrEqual(2);
136+
137+
// Verify same plugin instance
138+
const toolData = extractToolJson<{ counterInstanceId: string }>(inc1);
139+
expect(toolData.counterInstanceId).toBe(resourceData.counterInstanceId);
140+
});
141+
});
142+
143+
// ─── Cross-component consistency ───────────────────────────────────────
144+
145+
test.describe('Cross-Component Provider Consistency', () => {
146+
test('multiple resource reads use same provider instance', async ({ mcp }) => {
147+
const res1 = await mcp.resources.read('store://contents');
148+
const res2 = await mcp.resources.read('store://contents');
149+
150+
expect(res1).toBeSuccessful();
151+
expect(res2).toBeSuccessful();
152+
153+
const data1 = extractResourceJson<{ storeInstanceId: string }>(res1);
154+
const data2 = extractResourceJson<{ storeInstanceId: string }>(res2);
155+
expect(data1.storeInstanceId).toBe(data2.storeInstanceId);
156+
});
157+
158+
test('debug tool and resource resolve same provider instance', async ({ mcp }) => {
159+
const toolDebug = await mcp.tools.call('debug_providers', {});
160+
const resourceDebug = await mcp.resources.read('debug://providers');
161+
162+
expect(toolDebug).toBeSuccessful();
163+
expect(resourceDebug).toBeSuccessful();
164+
165+
const toolData = extractToolJson<{ storeInstanceId: string }>(toolDebug);
166+
const resourceData = extractResourceJson<{ storeInstanceId: string }>(resourceDebug);
167+
168+
expect(toolData.storeInstanceId).toBeDefined();
169+
expect(resourceData.storeInstanceId).toBeDefined();
170+
expect(toolData.storeInstanceId).toBe(resourceData.storeInstanceId);
171+
});
172+
});
173+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Config } from '@jest/types';
2+
import { createRequire } from 'module';
3+
4+
const require = createRequire(import.meta.url);
5+
const e2eCoveragePreset = require('../../../jest.e2e.coverage.preset.js');
6+
7+
const config: Config.InitialOptions = {
8+
displayName: 'demo-e2e-resource-providers',
9+
preset: '../../../jest.preset.js',
10+
testEnvironment: 'node',
11+
testMatch: ['<rootDir>/e2e/**/*.e2e.spec.ts'],
12+
testTimeout: 60000,
13+
maxWorkers: 1,
14+
setupFilesAfterEnv: ['<rootDir>/../../../libs/testing/src/setup.ts'],
15+
transformIgnorePatterns: ['node_modules/(?!(jose)/)'],
16+
transform: {
17+
'^.+\\.[tj]s$': [
18+
'@swc/jest',
19+
{
20+
jsc: {
21+
parser: {
22+
syntax: 'typescript',
23+
decorators: true,
24+
},
25+
transform: {
26+
decoratorMetadata: true,
27+
},
28+
target: 'es2022',
29+
},
30+
},
31+
],
32+
},
33+
moduleNameMapper: {
34+
'^@frontmcp/testing$': '<rootDir>/../../../libs/testing/src/index.ts',
35+
'^@frontmcp/sdk$': '<rootDir>/../../../libs/sdk/src/index.ts',
36+
'^@frontmcp/adapters$': '<rootDir>/../../../libs/adapters/src/index.ts',
37+
'^@frontmcp/auth$': '<rootDir>/../../../libs/auth/src/index.ts',
38+
'^@frontmcp/utils$': '<rootDir>/../../../libs/utils/src/index.ts',
39+
},
40+
coverageDirectory: '../../../coverage/e2e/demo-e2e-resource-providers',
41+
...e2eCoveragePreset,
42+
};
43+
44+
export default config;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "demo-e2e-resource-providers",
3+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "apps/e2e/demo-e2e-resource-providers/src",
5+
"projectType": "application",
6+
"tags": ["scope:demo", "type:e2e", "feature:resource-providers"],
7+
"targets": {
8+
"build": {
9+
"executor": "@nx/webpack:webpack",
10+
"outputs": ["{options.outputPath}"],
11+
"defaultConfiguration": "development",
12+
"options": {
13+
"target": "node",
14+
"compiler": "tsc",
15+
"outputPath": "dist/apps/e2e/demo-e2e-resource-providers",
16+
"main": "apps/e2e/demo-e2e-resource-providers/src/main.ts",
17+
"tsConfig": "apps/e2e/demo-e2e-resource-providers/tsconfig.app.json",
18+
"webpackConfig": "apps/e2e/demo-e2e-resource-providers/webpack.config.js",
19+
"generatePackageJson": true
20+
},
21+
"configurations": {
22+
"development": {},
23+
"production": {
24+
"optimization": true
25+
}
26+
}
27+
},
28+
"serve": {
29+
"executor": "nx:run-commands",
30+
"dependsOn": ["build"],
31+
"options": {
32+
"command": "node dist/apps/e2e/demo-e2e-resource-providers/main.js",
33+
"cwd": "{workspaceRoot}"
34+
}
35+
},
36+
"test": {
37+
"executor": "@nx/jest:jest",
38+
"outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-resource-providers"],
39+
"options": {
40+
"jestConfig": "apps/e2e/demo-e2e-resource-providers/jest.e2e.config.ts",
41+
"passWithNoTests": true
42+
}
43+
},
44+
"test:e2e": {
45+
"executor": "@nx/jest:jest",
46+
"outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-resource-providers-e2e"],
47+
"options": {
48+
"jestConfig": "apps/e2e/demo-e2e-resource-providers/jest.e2e.config.ts",
49+
"runInBand": true,
50+
"passWithNoTests": true
51+
}
52+
}
53+
}
54+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { App } from '@frontmcp/sdk';
2+
import { DataStoreService } from './providers/data-store.provider';
3+
import { CounterPlugin } from '../../plugins/counter/counter.plugin';
4+
import StoreSetTool from './tools/store-set.tool';
5+
import StoreGetTool from './tools/store-get.tool';
6+
import CounterIncrementTool from './tools/counter-increment.tool';
7+
import DebugProvidersTool from './tools/debug-providers.tool';
8+
import StoreContentsResource from './resources/store-contents.resource';
9+
import CounterStatusResource from './resources/counter-status.resource';
10+
import DebugProvidersResource from './resources/debug-providers.resource';
11+
12+
@App({
13+
name: 'main',
14+
providers: [DataStoreService],
15+
plugins: [CounterPlugin],
16+
tools: [StoreSetTool, StoreGetTool, CounterIncrementTool, DebugProvidersTool],
17+
resources: [StoreContentsResource, CounterStatusResource, DebugProvidersResource],
18+
})
19+
export class MainApp {}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Provider, ProviderScope } from '@frontmcp/sdk';
2+
import type { Token } from '@frontmcp/di';
3+
4+
export const DATA_STORE_TOKEN: Token<DataStoreService> = Symbol('DataStore');
5+
6+
export interface DataStoreEntry {
7+
key: string;
8+
value: string;
9+
createdAt: number;
10+
}
11+
12+
@Provider({
13+
name: 'DataStoreService',
14+
scope: ProviderScope.GLOBAL,
15+
})
16+
export class DataStoreService {
17+
private readonly store = new Map<string, DataStoreEntry>();
18+
readonly instanceId = `store-${Math.random().toString(36).substring(2, 10)}`;
19+
20+
set(key: string, value: string): void {
21+
this.store.set(key, { key, value, createdAt: Date.now() });
22+
}
23+
24+
get(key: string): DataStoreEntry | undefined {
25+
return this.store.get(key);
26+
}
27+
28+
getAll(): DataStoreEntry[] {
29+
return Array.from(this.store.values());
30+
}
31+
32+
getInstanceId(): string {
33+
return this.instanceId;
34+
}
35+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Resource, ResourceContext } from '@frontmcp/sdk';
2+
3+
/**
4+
* Resource that accesses the CounterPlugin via context extension (this.counter).
5+
*
6+
* BUG UNDER TEST: Plugin context extensions should work in resources the same
7+
* way they work in tools. If the plugin's exported provider is not in the
8+
* resource's provider hierarchy, this.counter will throw
9+
* ProviderNotRegisteredError.
10+
*/
11+
@Resource({
12+
uri: 'counter://status',
13+
name: 'Counter Status',
14+
description: 'Reads counter status via plugin context extension',
15+
mimeType: 'application/json',
16+
})
17+
export default class CounterStatusResource extends ResourceContext {
18+
async execute() {
19+
const count = this.counter.getCount();
20+
const instanceId = this.counter.getInstanceId();
21+
return { count, counterInstanceId: instanceId };
22+
}
23+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Resource, ResourceContext } from '@frontmcp/sdk';
2+
import { DataStoreService } from '../providers/data-store.provider';
3+
4+
@Resource({
5+
uri: 'debug://providers',
6+
name: 'Debug Providers',
7+
description: 'Debug resource that reports provider resolution details',
8+
mimeType: 'application/json',
9+
})
10+
export default class DebugProvidersResource extends ResourceContext {
11+
async execute() {
12+
let storeInstanceId = 'NOT_RESOLVED';
13+
let error = '';
14+
15+
try {
16+
const store = this.get(DataStoreService);
17+
storeInstanceId = store.getInstanceId();
18+
} catch (e: unknown) {
19+
error = e instanceof Error ? `${e.constructor.name}: ${e.message}` : String(e);
20+
}
21+
22+
return {
23+
storeInstanceId,
24+
error: error || undefined,
25+
};
26+
}
27+
}

0 commit comments

Comments
 (0)